From a440d0bced746f5ac915a145bcd99491caaa7f02 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:50:35 +1000 Subject: [PATCH] refactor(app): port language, drafts, pairing, review panel, open-in-app and upsell stores to plain codecs Also replaces the Effect Iterable pipeline in the provider catalog with array methods. --- .../app/src/providers/catalog/providers.ts | 33 +++----- packages/app/src/runtime/i18n/language.tsx | 25 +++--- .../src/runtime/persistence/consumers.test.ts | 3 +- .../app/src/runtime/persistence/drafts.ts | 15 ++-- packages/app/src/servers/connect/pairing.ts | 25 +++--- .../app/src/session/files/open-in-app.test.ts | 6 +- .../app/src/session/files/open-in-app.tsx | 8 +- .../app/src/session/review/panel-state.ts | 20 +++-- .../session/usage-exceeded-dialogs.test.ts | 80 +++++++++---------- .../src/session/usage-exceeded-dialogs.tsx | 14 ++-- 10 files changed, 109 insertions(+), 120 deletions(-) diff --git a/packages/app/src/providers/catalog/providers.ts b/packages/app/src/providers/catalog/providers.ts index 27273c4a822..b10b089a41b 100644 --- a/packages/app/src/providers/catalog/providers.ts +++ b/packages/app/src/providers/catalog/providers.ts @@ -1,7 +1,6 @@ import { useData } from "@/runtime/server/current" import { useServerSDK } from "@/runtime/server/client" import { normalizeProviderList } from "@/runtime/server/global-sync/utils" -import { Iterable, pipe } from "effect" import { createEffect, createMemo, type Accessor } from "solid-js" import type { ProviderListResponse } from "@/runtime/server/types" import { useIntegrations } from "./integrations" @@ -53,34 +52,24 @@ export function useProviders(directory: Accessor) { .filter((integration) => popularProviderSet.has(integration.id)) .map((integration) => ({ id: integration.id, name: integration.name })) const seen = new Set(catalog.map((integration) => integration.id)) - return pipe( - providers().all, - Iterable.map(([, p]) => p), - Iterable.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id)), - Iterable.map((p) => ({ id: p.id, name: p.name })), - (v) => [...catalog, ...v], - ) + const more = [...providers().all.values()] + .filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id)) + .map((p) => ({ id: p.id, name: p.name })) + return [...catalog, ...more] }, connected: () => { const connected = new Set(providers().connected) - return pipe( - providers().all, - Iterable.map(([, p]) => p), - Iterable.filter((p) => connected.has(p.id)), - (v) => Array.from(v), - ) + return [...providers().all.values()].filter((p) => connected.has(p.id)) }, paid: () => { const connected = new Set(providers().connected) - const paid = [ - ...Iterable.filter( - providers().all, - ([id]) => - connected.has(id) && - (id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)), - ), - ] + const paid = [...providers().all].filter( + ([id]) => + connected.has(id) && + (id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)), + ) return paid }, } } + diff --git a/packages/app/src/runtime/i18n/language.tsx b/packages/app/src/runtime/i18n/language.tsx index cc3bc0ae8cc..e4339431be1 100644 --- a/packages/app/src/runtime/i18n/language.tsx +++ b/packages/app/src/runtime/i18n/language.tsx @@ -1,7 +1,7 @@ import { flatten, resolveTemplate, translator, type Flatten } from "@solid-primitives/i18n" import { createEffect, createMemo, createResource, type JSX } from "solid-js" import { createStore } from "solid-js/store" -import { Option, Schema, SchemaGetter } from "effect" +import { Codec } from "@/runtime/persistence/codec" import { createSimpleContext } from "@opencode/ui/context" import { I18nProvider, @@ -12,7 +12,6 @@ import { type UiPluralCategory, } from "@opencode/ui/context/i18n" import { Persist, persisted } from "@/runtime/persistence/storage" -import { Persistence } from "@/runtime/persistence/schema" import en from "@/runtime/i18n/en" import { dict } from "@opencode/ui/i18n/en" import { @@ -56,14 +55,9 @@ function cookie(locale: Locale) { const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES -const LocaleSchema = Schema.Literals(DESKTOP_NATIVE_LOCALES) -const StoredLocaleSchema = Schema.Struct({ - locale: Schema.String.pipe( - Schema.decodeTo(LocaleSchema, { - decode: SchemaGetter.transform(normalizeLocale), - encode: SchemaGetter.transform((locale) => locale), - }), - ), +const LocaleSchema = Codec.literals(DESKTOP_NATIVE_LOCALES) +const StoredLocaleSchema = Codec.struct({ + locale: Codec.transform(Codec.string, { decode: normalizeLocale, encode: (locale) => locale }), }) const INTL = DESKTOP_NATIVE_LOCALE_TAGS @@ -160,11 +154,11 @@ function detectLocale(): Locale { } export function normalizeLocale(value: string): Locale { - return Option.getOrElse(Schema.decodeUnknownOption(LocaleSchema)(value), () => "en") + return Codec.decodeOption(LocaleSchema, value) ?? "en" } -export const languageSchema = Persistence.struct({ - locale: StoredLocaleSchema.fields.locale, +export const languageSchema = Codec.struct({ + locale: StoredLocaleSchema.fields.locale, }) function readStoredLocale() { @@ -172,9 +166,7 @@ function readStoredLocale() { try { const raw = localStorage.getItem("opencode.global.dat:language") if (!raw) return - const next = Schema.decodeUnknownOption(Schema.fromJsonString(StoredLocaleSchema))(raw) - if (Option.isNone(next)) return - return next.value.locale + return Codec.decodeOption(Codec.fromJsonString(StoredLocaleSchema), raw)?.locale } catch { return } @@ -296,3 +288,4 @@ export function UiI18nBridge(props: { children?: JSX.Element }) { ) } + diff --git a/packages/app/src/runtime/persistence/consumers.test.ts b/packages/app/src/runtime/persistence/consumers.test.ts index 4ab9a98ca21..95a11eef88a 100644 --- a/packages/app/src/runtime/persistence/consumers.test.ts +++ b/packages/app/src/runtime/persistence/consumers.test.ts @@ -104,7 +104,7 @@ describe("persisted consumer schemas", () => { }) test("language preserves runtime defaults and normalizes unsupported locales to English", () => { - const decode = Schema.decodeUnknownSync(Persistence.withInitial(languageSchema, { locale: "fr" })) + const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(languageSchema, { locale: "fr" }), input) expect(decode({})).toEqual({ locale: "fr" }) expect(decode({ locale: undefined })).toEqual({ locale: "fr" }) expect(decode({ locale: 42 })).toEqual({ locale: "fr" }) @@ -113,3 +113,4 @@ describe("persisted consumer schemas", () => { }) }) + diff --git a/packages/app/src/runtime/persistence/drafts.ts b/packages/app/src/runtime/persistence/drafts.ts index bb2751ac405..22c4deffffc 100644 --- a/packages/app/src/runtime/persistence/drafts.ts +++ b/packages/app/src/runtime/persistence/drafts.ts @@ -1,5 +1,5 @@ import type { AsyncStorage } from "@solid-primitives/storage" -import { Option, Schema } from "effect" +import { Codec } from "./codec" export type BlobReference = { id: string; url: string } @@ -312,12 +312,12 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = { getItem: async (key) => { const value = await driver.get(key) if (value === null) return null - const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(value) - // Let the owning persistence codec apply its invalid-document policy. - if (Option.isNone(parsed)) return value - // A loaded document is live in the composer: pin its images before decode mints their URLs. - retain(key, imageIDs(parsed.value), grace) - return JSON.stringify(await decode(parsed.value)) + const parsed = Codec.fromJsonString(Codec.unknown).decode(value) + // Let the owning persistence codec apply its invalid-document policy. + if (parsed === Codec.INVALID) return value + // A loaded document is live in the composer: pin its images before decode mints their URLs. + retain(key, imageIDs(parsed), grace) + return JSON.stringify(await decode(parsed)) }, setItem: (key, value) => setDocument(key, JSON.parse(value)), setDocument, @@ -455,3 +455,4 @@ export async function blobDataUrl(blob: BlobReference, mime: string) { export function createLegacyBlobReference(dataUrl: string): BlobReference { return { id: dataUrl, url: dataUrl } } + diff --git a/packages/app/src/servers/connect/pairing.ts b/packages/app/src/servers/connect/pairing.ts index 66e3c6d3802..7ffe4b6380f 100644 --- a/packages/app/src/servers/connect/pairing.ts +++ b/packages/app/src/servers/connect/pairing.ts @@ -1,12 +1,12 @@ -import { Option, Schema } from "effect" +import { Codec } from "@/runtime/persistence/codec" import { normalizeServerUrl } from "@/runtime/server/registry" -const pairing = Schema.fromJsonString( - Schema.Struct({ - urls: Schema.Array(Schema.String), - username: Schema.Literal("opencode"), - password: Schema.String, - }), +const pairing = Codec.fromJsonString( + Codec.struct({ + urls: Codec.array(Codec.string), + username: Codec.literal("opencode"), + password: Codec.string, + }), ) export function serverAddress(value: string) { @@ -20,9 +20,10 @@ export function serverAddress(value: string) { } export function decodePairingCode(value: string) { - const result = Schema.decodeUnknownOption(pairing)(value) - if (Option.isNone(result)) return - const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))] - if (!urls.length) return - return { urls, password: result.value.password } + const result = Codec.decodeOption(pairing, value) + if (!result) return + const urls = [...new Set(result.urls.map(serverAddress).filter((url) => url !== undefined))] + if (!urls.length) return + return { urls, password: result.password } } + diff --git a/packages/app/src/session/files/open-in-app.test.ts b/packages/app/src/session/files/open-in-app.test.ts index c1d90a80a6c..5b30a242097 100644 --- a/packages/app/src/session/files/open-in-app.test.ts +++ b/packages/app/src/session/files/open-in-app.test.ts @@ -1,9 +1,8 @@ import { describe, expect, test } from "bun:test" -import { Schema } from "effect" import { OPEN_APPS, OpenAppPreferences } from "./open-in-app" -import { Persistence } from "@/runtime/persistence/schema" +import { Codec } from "@/runtime/persistence/codec" -const decode = Schema.decodeUnknownSync(Persistence.withInitial(OpenAppPreferences, { app: "finder" })) +const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(OpenAppPreferences, { app: "finder" }), input)) describe("open app preferences", () => { test.each([...OPEN_APPS])("preserves the %s preference", (app) => { @@ -18,3 +17,4 @@ describe("open app preferences", () => { expect(decode({})).toEqual({ app: "finder" }) }) }) + diff --git a/packages/app/src/session/files/open-in-app.tsx b/packages/app/src/session/files/open-in-app.tsx index c46ff341bce..74296030d5b 100644 --- a/packages/app/src/session/files/open-in-app.tsx +++ b/packages/app/src/session/files/open-in-app.tsx @@ -5,8 +5,7 @@ import { usePlatform } from "@/runtime/platform/platform" import { Persist, persisted } from "@/runtime/persistence/storage" import { showToast } from "@/shell/notifications/toast" import { useServer } from "@/runtime/server/current" -import { Schema } from "effect" -import { Persistence } from "@/runtime/persistence/schema" +import { Codec } from "@/runtime/persistence/codec" import { fileManagerApp } from "@/home/projects/file-manager" import { openInAppParentPath } from "@/session/files/open-in-app-path" @@ -30,8 +29,8 @@ export const OPEN_APPS = [ export type OpenApp = (typeof OPEN_APPS)[number] export type OpenAppOS = "macos" | "windows" | "linux" | "unknown" -export const OpenAppPreferences = Persistence.struct({ - app: Schema.Literals(OPEN_APPS), +export const OpenAppPreferences = Codec.struct({ + app: Codec.literals(OPEN_APPS), }) const appExistence = new Map>() @@ -242,3 +241,4 @@ function checkAppExists(platform: ReturnType, app: string) { appExistence.set(app, request) return request } + diff --git a/packages/app/src/session/review/panel-state.ts b/packages/app/src/session/review/panel-state.ts index e2339453eaa..3bfca142e4c 100644 --- a/packages/app/src/session/review/panel-state.ts +++ b/packages/app/src/session/review/panel-state.ts @@ -5,17 +5,20 @@ import { type SessionReviewExpandMode, } from "@opencode/session-ui/v2/session-review-v2" import { createSignal } from "solid-js" -import { Schema } from "effect" +import { Codec } from "@/runtime/persistence/codec" import type { Platform } from "@/runtime/platform/platform" import { Persist, persisted } from "@/runtime/persistence/storage" -import { Persistence } from "@/runtime/persistence/schema" -const ReviewPanel = Persistence.struct({ - sidebarOpened: Schema.Boolean, - sidebarWidth: Schema.Finite.check( - Schema.isBetween({ minimum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, maximum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX }), - ), - expandMode: Schema.Literals(["expand", "collapse"]), +const ReviewPanel = Codec.struct({ + sidebarOpened: Codec.boolean, + sidebarWidth: Codec.make( + (v) => + typeof v === "number" && v >= SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN && v <= SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX + ? v + : Codec.INVALID, + (v) => v, + ), + expandMode: Codec.literals(["expand", "collapse"]), }) export function createReviewPanelState(platform?: Platform) { @@ -47,3 +50,4 @@ export function createReviewPanelState(platform?: Platform) { } export type ReviewPanelState = ReturnType + diff --git a/packages/app/src/session/usage-exceeded-dialogs.test.ts b/packages/app/src/session/usage-exceeded-dialogs.test.ts index c57aba79d84..0e08e8bceac 100644 --- a/packages/app/src/session/usage-exceeded-dialogs.test.ts +++ b/packages/app/src/session/usage-exceeded-dialogs.test.ts @@ -1,40 +1,40 @@ -import { describe, expect, test } from "bun:test" -import { Schema } from "effect" -import { GoUpsellState } from "./usage-exceeded-dialogs" -import { Persistence } from "@/runtime/persistence/schema" - -const decode = Schema.decodeUnknownSync( - Persistence.withInitial(GoUpsellState, { - go_upsell_last_seen_at: null, - go_upsell_dont_show: null, - go_upsell_account_rate_limit_last_seen_at: null, - go_upsell_account_rate_limit_dont_show: null, - }), -) - -describe("usage exceeded preferences", () => { - test("defaults unseen prompts", () => { - expect(decode({})).toEqual({ - go_upsell_last_seen_at: null, - go_upsell_dont_show: null, - go_upsell_account_rate_limit_last_seen_at: null, - go_upsell_account_rate_limit_dont_show: null, - }) - }) - - test("preserves timestamps while recovering malformed siblings", () => { - expect( - decode({ - go_upsell_last_seen_at: 123, - go_upsell_dont_show: "true", - go_upsell_account_rate_limit_last_seen_at: Infinity, - go_upsell_account_rate_limit_dont_show: 456, - }), - ).toEqual({ - go_upsell_last_seen_at: 123, - go_upsell_dont_show: null, - go_upsell_account_rate_limit_last_seen_at: null, - go_upsell_account_rate_limit_dont_show: 456, - }) - }) -}) +import { describe, expect, test } from "bun:test" +import { GoUpsellState } from "./usage-exceeded-dialogs" +import { Codec } from "@/runtime/persistence/codec" + +const schema = Codec.withInitial(GoUpsellState, { + go_upsell_last_seen_at: null, + go_upsell_dont_show: null, + go_upsell_account_rate_limit_last_seen_at: null, + go_upsell_account_rate_limit_dont_show: null, +}) +const decode = (input: unknown) => Codec.decodeOrThrow(schema, input) + +describe("usage exceeded preferences", () => { + test("defaults unseen prompts", () => { + expect(decode({})).toEqual({ + go_upsell_last_seen_at: null, + go_upsell_dont_show: null, + go_upsell_account_rate_limit_last_seen_at: null, + go_upsell_account_rate_limit_dont_show: null, + }) + }) + + test("preserves timestamps while recovering malformed siblings", () => { + expect( + decode({ + go_upsell_last_seen_at: 123, + go_upsell_dont_show: "true", + go_upsell_account_rate_limit_last_seen_at: Infinity, + go_upsell_account_rate_limit_dont_show: 456, + }), + ).toEqual({ + go_upsell_last_seen_at: 123, + go_upsell_dont_show: null, + go_upsell_account_rate_limit_last_seen_at: null, + go_upsell_account_rate_limit_dont_show: 456, + }) + }) +}) + + diff --git a/packages/app/src/session/usage-exceeded-dialogs.tsx b/packages/app/src/session/usage-exceeded-dialogs.tsx index 09e05d97de1..b15fb9d6eee 100644 --- a/packages/app/src/session/usage-exceeded-dialogs.tsx +++ b/packages/app/src/session/usage-exceeded-dialogs.tsx @@ -2,8 +2,7 @@ import { useWorkspaceLocation } from "@/workspaces/location" import { Persist, persisted } from "@/runtime/persistence/storage" import type { SessionStatus } from "@opencode/client/promise" import { onCleanup } from "solid-js" -import { Schema } from "effect" -import { Persistence } from "@/runtime/persistence/schema" +import { Codec } from "@/runtime/persistence/codec" import { useSessionLayout } from "./session-layout" import { useDialog, useI18n } from "@opencode/ui/context" import { DialogUsageExceeded } from "@/providers/connect/usage-exceeded" @@ -15,11 +14,11 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_don const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"]) -export const GoUpsellState = Persistence.struct({ - [GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite), - [GO_UPSELL_FREE_TIER_DONT_SHOW]: Schema.NullOr(Schema.Finite), - [GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite), - [GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: Schema.NullOr(Schema.Finite), +export const GoUpsellState = Codec.struct({ + [GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Codec.nullOr(Codec.number), + [GO_UPSELL_FREE_TIER_DONT_SHOW]: Codec.nullOr(Codec.number), + [GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: Codec.nullOr(Codec.number), + [GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: Codec.nullOr(Codec.number), }) function goUpsellKeys(status: SessionStatus) { @@ -106,3 +105,4 @@ export function useUsageExceededDialogs() { }), ) } +