diff --git a/packages/components/add-provider/package.json b/packages/components/add-provider/package.json index dad9a71..9f28286 100644 --- a/packages/components/add-provider/package.json +++ b/packages/components/add-provider/package.json @@ -9,6 +9,7 @@ "dependencies": { "@echo/core-types": "^1.0.0", "@echo/components-provider-status": "^1.0.0", + "@echo/components-shared-controllers": "^1.0.0", "@echo/services-bootstrap-runtime": "^1.0.0", "effect": "^3.6.5", "lit": "^3.2.0", diff --git a/packages/components/add-provider/src/add-provider.ts b/packages/components/add-provider/src/add-provider.ts index cccf5c7..11fa325 100644 --- a/packages/components/add-provider/src/add-provider.ts +++ b/packages/components/add-provider/src/add-provider.ts @@ -3,14 +3,14 @@ import { type FolderMetadata, type ProviderMetadata, } from "@echo/core-types"; -import { Task } from "@lit/task"; import { LitElement, html } from "lit"; import { customElement, property } from "lit/decorators.js"; -import { getOrCreateRuntime } from "@echo/services-bootstrap-runtime"; import type { ProviderLoadedEvent } from "./provider-loader"; import "@echo/components-provider-status"; import "./provider-loader"; import "./select-root"; +import { EffectController } from "@echo/components-shared-controllers"; +import { Match } from "effect"; type ProviderStatus = | { _tag: "LoadingProviders" } @@ -27,34 +27,44 @@ export class AddProvider extends LitElement { @property() private _providerStatus: ProviderStatus = { _tag: "LoadingProviders" }; - // @ts-expect-error "Task executes automatically" - private _availableProvidersTask = new Task(this, { - task: () => - getOrCreateRuntime() - .runPromise(AddProviderWorkflow.availableProviders) - .then((availableProviders) => { - this._providerStatus = { - _tag: "ProvidersLoaded", - availableProviders, - }; - }), - args: () => [], - }); + connectedCallback(): void { + super.connectedCallback(); + + new EffectController(this, AddProviderWorkflow.availableProviders, { + complete: (availableProviders) => { + this._providerStatus = { + _tag: "ProvidersLoaded", + availableProviders, + }; + }, + }); + } render() { - return this._providerStatus._tag === "LoadingProviders" - ? html`

Loading providers...

` - : this._providerStatus._tag === "ProvidersLoaded" - ? html` html`

Loading providers...

`), + Match.tag( + "ProvidersLoaded", + ({ availableProviders }) => + html`` - : this._providerStatus._tag === "WaitingForRootFolderSelection" - ? html`` - : html``; + >
`, + ), + Match.tag( + "WaitingForRootFolderSelection", + ({ folders }) => + html``, + ), + Match.tag( + "ProviderStarted", + () => html``, + ), + Match.exhaustive, + ); } private _onProviderLoaded(event: ProviderLoadedEvent) { diff --git a/packages/components/add-provider/src/provider-loader.ts b/packages/components/add-provider/src/provider-loader.ts index ae7742b..89a3fef 100644 --- a/packages/components/add-provider/src/provider-loader.ts +++ b/packages/components/add-provider/src/provider-loader.ts @@ -1,10 +1,10 @@ +import { EffectFnController } from "@echo/components-shared-controllers/src/effect-fn.controller"; import { AddProviderWorkflow, type FolderMetadata, type ProviderMetadata, } from "@echo/core-types"; -import { getOrCreateRuntime } from "@echo/services-bootstrap-runtime"; -import { Task } from "@lit/task"; +import { Match } from "effect"; import { LitElement, html } from "lit"; import { customElement, property } from "lit/decorators.js"; @@ -18,58 +18,72 @@ export class ProviderLoadedEvent extends Event { } } +type LoaderStatus = + | { _tag: "Initial" } + | { _tag: "LoadingProvider" } + | { _tag: "WaitingToConnect"; metadata: ProviderMetadata } + | { _tag: "ConnectingToProvider" } + | { _tag: "Connected" }; + /** * Component that displays a list of available providers and loads them upon selection. */ @customElement("provider-loader") export class ProviderLoader extends LitElement { + @property() + private _loaderStatus: LoaderStatus = { _tag: "Initial" }; + @property({ type: Array }) availableProviders: ProviderMetadata[] = []; - private _loadProvider = new Task(this, { - task: ([provider]: [ProviderMetadata]) => - getOrCreateRuntime().runPromise( - AddProviderWorkflow.loadProvider(provider), - ), - autoRun: false, - }); - - private _connectToProvider = new Task(this, { - task: () => - getOrCreateRuntime().runPromise(AddProviderWorkflow.connectToProvider()), - autoRun: false, - }); + private _loadProvider = new EffectFnController( + this, + (metadata: ProviderMetadata) => AddProviderWorkflow.loadProvider(metadata), + { + complete: (metadata) => { + this._loaderStatus = { _tag: "WaitingToConnect", metadata }; + }, + }, + ); - // @ts-expect-error "Task executes automatically" - private _notifyProviderLoaded = new Task(this, { - args: () => [this._connectToProvider.value], - task: ([rootFolder]) => { - if (rootFolder) { + private _connectToProvider = new EffectFnController( + this, + () => AddProviderWorkflow.connectToProvider, + { + pending: () => { + this._loaderStatus = { _tag: "ConnectingToProvider" }; + }, + complete: (rootFolder) => { + this._loaderStatus = { _tag: "Connected" }; this.dispatchEvent(new ProviderLoadedEvent(rootFolder)); - } + }, }, - }); + ); render() { - return this._connectToProvider.render({ - initial: () => - this._loadProvider.render({ - initial: () => - this.availableProviders.map( - (provider) => html` - - `, - ), - complete: (providerMetadata) => - html``, - }), - pending: () => html`

Connecting...

`, - complete: () => html`

Connected!

`, - }); + return Match.value(this._loaderStatus).pipe( + Match.tag("Initial", () => + this.availableProviders.map( + (provider) => html` + + `, + ), + ), + Match.tag( + "WaitingToConnect", + ({ metadata }) => html` + + `, + ), + Match.tag("LoadingProvider", () => html`

Loading provider...

`), + Match.tag("ConnectingToProvider", () => html`

Connecting...

`), + Match.tag("Connected", () => html`

Connected!

`), + Match.exhaustive, + ); } } diff --git a/packages/components/add-provider/src/select-root.ts b/packages/components/add-provider/src/select-root.ts index a2b7842..f2bfd04 100644 --- a/packages/components/add-provider/src/select-root.ts +++ b/packages/components/add-provider/src/select-root.ts @@ -1,6 +1,5 @@ +import { EffectFnController } from "@echo/components-shared-controllers/src/effect-fn.controller"; import { AddProviderWorkflow, type FolderMetadata } from "@echo/core-types"; -import { getOrCreateRuntime } from "@echo/services-bootstrap-runtime"; -import { Task } from "@lit/task"; import { LitElement, html, nothing } from "lit"; import { customElement, property } from "lit/decorators.js"; @@ -23,23 +22,13 @@ export class SelectRoot extends LitElement { @property({ type: Array }) availableFolders: FolderMetadata[] = []; - private _selectRoot = new Task(this, { - task: ([rootFolder]: [FolderMetadata]) => - getOrCreateRuntime().runPromise( - AddProviderWorkflow.selectRoot(rootFolder), - ), - autoRun: false, - }); - - // @ts-expect-error "Task executes automatically" - private _notifyProviderStarted = new Task(this, { - args: () => [this._selectRoot.value], - task: ([completed]) => { - if (completed !== undefined) { - this.dispatchEvent(new ProviderStartedEvent()); - } + private _selectRoot = new EffectFnController( + this, + (rootFolder: FolderMetadata) => AddProviderWorkflow.selectRoot(rootFolder), + { + complete: () => this.dispatchEvent(new ProviderStartedEvent()), }, - }); + ); render() { return this._selectRoot.render({ @@ -47,7 +36,7 @@ export class SelectRoot extends LitElement {

Select a root folder:

${this.availableFolders.map( (folder) => - html``, )} diff --git a/packages/components/shared-controllers/index.ts b/packages/components/shared-controllers/index.ts index 0aee6d0..d430e9e 100644 --- a/packages/components/shared-controllers/index.ts +++ b/packages/components/shared-controllers/index.ts @@ -1 +1,2 @@ +export * from "./src/effect.controller"; export * from "./src/stream-effect.controller"; diff --git a/packages/components/shared-controllers/src/effect-fn.controller.ts b/packages/components/shared-controllers/src/effect-fn.controller.ts new file mode 100644 index 0000000..0e15354 --- /dev/null +++ b/packages/components/shared-controllers/src/effect-fn.controller.ts @@ -0,0 +1,93 @@ +import { + getOrCreateRuntime, + type EchoRuntimeServices, +} from "@echo/services-bootstrap-runtime"; +import { Effect } from "effect"; +import type { ReactiveController, ReactiveControllerHost } from "lit"; +import type { StatusListener } from "./shared.interface"; + +type StreamStatus = + | { _tag: "Initial" } + | { _tag: "Pending" } + | { _tag: "Complete"; result: A } + | { _tag: "Error"; error: E }; + +/** + * Controller that takes a function that produces an effect and exposes a + * method to execute the effect and render the different states of the effect. + */ +export class EffectFnController implements ReactiveController { + private host: ReactiveControllerHost; + private _status: StreamStatus = { _tag: "Initial" }; + + constructor( + host: ReactiveControllerHost, + private readonly _effectFn: ( + p: P, + ) => Effect.Effect, + /** + * Optional listeners that will be called when the effect produces a value + * or errors. Only meant to be used by hosts that require side effects + * when the effect produces a value or errors, otherwise use the render + * method to render the different states of the effect. + */ + private readonly _listeners?: Omit, "initial">, + ) { + (this.host = host).addController(this); + } + + hostConnected(): void {} + + /** + * Runs the effect with the given parameters. This produces a value or an error + * that gets notified to the host and triggers the listeners if they are provided. + */ + run(params: P) { + const consumer = this._effectFn(params).pipe( + Effect.tap((result) => + Effect.sync(() => this.handleUpdate$({ _tag: "Complete", result })), + ), + Effect.tapError((error) => + Effect.sync(() => this.handleUpdate$({ _tag: "Error", error })), + ), + ); + + this.handleUpdate$({ + _tag: "Pending", + }); + getOrCreateRuntime().runPromise(consumer); + } + + /** + * Maps the different states of the effect to a renderer. + */ + render(renderer: StatusListener) { + switch (this._status._tag) { + case "Initial": + return renderer.initial?.(); + case "Pending": + return renderer.pending?.(); + case "Complete": + return renderer.complete?.(this._status.result); + case "Error": + return renderer.error?.(this._status.error); + } + } + + private handleUpdate$(state: StreamStatus) { + switch (state._tag) { + case "Pending": + this._listeners?.pending?.(); + break; + case "Complete": + this._listeners?.complete?.(state.result); + break; + case "Error": + this._listeners?.error?.(state.error); + break; + } + + this._status = state; + this.host.requestUpdate(); + } +} diff --git a/packages/components/shared-controllers/src/effect.controller.ts b/packages/components/shared-controllers/src/effect.controller.ts new file mode 100644 index 0000000..37b15c8 --- /dev/null +++ b/packages/components/shared-controllers/src/effect.controller.ts @@ -0,0 +1,33 @@ +import { type EchoRuntimeServices } from "@echo/services-bootstrap-runtime"; +import { Effect } from "effect"; +import type { ReactiveControllerHost } from "lit"; +import type { StatusListener } from "./shared.interface"; +import { EffectFnController } from "./effect-fn.controller"; + +/** + * Controller that takes an effect that can be executed by the default runtime + * of the application, and exposes a render method that maps each different + * status of the effect to a renderer. This is meant to be used with effects + * that are one-shot, meaning they only produce a single value. For multiple + * values, use the StreamEffectController, which can handle streams and + * subscription refs. + */ +export class EffectController extends EffectFnController { + constructor( + host: ReactiveControllerHost, + _effect: Effect.Effect, + /** + * Optional listeners that will be called when the effect produces a value + * or errors. Only meant to be used by hosts that require side effects + * when the effect produces a value or errors, otherwise use the render + * method to render the different states of the effect. + */ + _listeners?: Omit, "initial">, + ) { + super(host, () => _effect, _listeners); + } + + hostConnected(): void { + this.run(); + } +} diff --git a/packages/components/shared-controllers/src/shared.interface.ts b/packages/components/shared-controllers/src/shared.interface.ts new file mode 100644 index 0000000..06342d3 --- /dev/null +++ b/packages/components/shared-controllers/src/shared.interface.ts @@ -0,0 +1,24 @@ +/** + * Defines a renderer that can render each different status of an effect. + */ +export type StatusListener = { + /** + * Called when the effect has finished yet. + */ + initial?: () => unknown; + + /** + * Called when the effect is yet to finish. + */ + pending?: () => unknown; + + /** + * Called when the effect finishes. + */ + complete?: (result: A) => unknown; + + /** + * Called when the effect errors. + */ + error?: (error: E) => unknown; +}; diff --git a/packages/core/types/src/services/workflows/add-provider.workflow.ts b/packages/core/types/src/services/workflows/add-provider.workflow.ts index f43ea26..a3f712e 100644 --- a/packages/core/types/src/services/workflows/add-provider.workflow.ts +++ b/packages/core/types/src/services/workflows/add-provider.workflow.ts @@ -19,7 +19,7 @@ export type IAddProviderWorkflow = { readonly loadProvider: ( metadata: ProviderMetadata, ) => Effect.Effect; - readonly connectToProvider: () => Effect.Effect< + readonly connectToProvider: Effect.Effect< FolderMetadata[], AuthenticationError | FileBasedProviderError >; diff --git a/packages/services/add-provider-workflow/src/add-provider.machine.ts b/packages/services/add-provider-workflow/src/add-provider.machine.ts index b18be16..0bfe64f 100644 --- a/packages/services/add-provider-workflow/src/add-provider.machine.ts +++ b/packages/services/add-provider-workflow/src/add-provider.machine.ts @@ -195,7 +195,7 @@ export const AddProviderWorkflowLive = Layer.scoped( ), ), loadProvider: (metadata) => actor.send(new LoadProvider({ metadata })), - connectToProvider: () => actor.send(new ConnectToProvider({})), + connectToProvider: actor.send(new ConnectToProvider({})), selectRoot: (rootFolder) => actor.send(new SelectRoot({ rootFolder })), }; }), diff --git a/packages/web/package.json b/packages/web/package.json index 1b6ebcc..8fdecba 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@echo/components-add-provider": "^1.0.0", + "@echo/components-shared-controllers": "^1.0.0", "@echo/core-types": "^1.0.0", "@echo/services-bootstrap-runtime": "^1.0.0", "@echo/services-bootstrap-workers": "^1.0.0", diff --git a/packages/web/src/main.ts b/packages/web/src/main.ts index c5e0b3e..729bcc8 100644 --- a/packages/web/src/main.ts +++ b/packages/web/src/main.ts @@ -1,9 +1,8 @@ import { LitElement, html } from "lit"; import { customElement } from "lit/decorators.js"; import { initializeWorkers } from "@echo/services-bootstrap-workers"; -import { getOrCreateRuntime } from "@echo/services-bootstrap-runtime"; -import { Task } from "@lit/task"; import { AppInit } from "@echo/core-types"; +import { EffectController } from "@echo/components-shared-controllers"; import "@echo/components-add-provider"; initializeWorkers(); @@ -13,13 +12,10 @@ initializeWorkers(); */ @customElement("app-root") export class MyElement extends LitElement { - private _initTask = new Task(this, { - task: () => getOrCreateRuntime().runPromise(AppInit.init), - args: () => [], - }); + private _init = new EffectController(this, AppInit.init); render() { - return this._initTask.render({ + return this._init.render({ initial: () => html`

Initializing Echo...

`, complete: () => html`