mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-20 23:57:40 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a57105b2c | ||
|
|
7930c5f676 | ||
|
|
a440d0bced |
@@ -10,7 +10,8 @@
|
||||
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
|
||||
"./updater": "./src/shell/updates/types.ts",
|
||||
"./wsl/types": "./src/servers/wsl/types.ts",
|
||||
"./ssh": "./src/servers/ssh/types.ts",
|
||||
"./ssh": "./src/servers/ssh/types.ts",
|
||||
"./ssh/schema": "./src/servers/ssh/schema.ts",
|
||||
"./vite": "./vite.js",
|
||||
"./index.css": "./src/index.css"
|
||||
},
|
||||
@@ -94,3 +95,4 @@
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string | undefined>) {
|
||||
.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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,11 @@ import { base64Encode } from "@opencode/util/encode"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { useModels } from "@/providers/models/models"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { hasCustomAgent, resolveAgent } from "./agent"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./variant"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
@@ -18,46 +17,45 @@ import { useServerSDK } from "@/runtime/server/client"
|
||||
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
|
||||
import { useConfiguredModel } from "./configured"
|
||||
|
||||
const ModelKeySchema = Schema.Struct({
|
||||
providerID: Schema.String,
|
||||
modelID: Schema.String,
|
||||
variant: Schema.optional(Schema.String),
|
||||
const ModelKeySchema = Codec.struct({
|
||||
providerID: Codec.string,
|
||||
modelID: Codec.string,
|
||||
variant: Codec.optional(Codec.string),
|
||||
})
|
||||
export type ModelKey = typeof ModelKeySchema.Type
|
||||
|
||||
const ChoiceSchema = Schema.Struct({
|
||||
model: Persistence.optional(ModelKeySchema),
|
||||
variant: Persistence.optional(Schema.NullOr(Schema.String)),
|
||||
const ChoiceSchema = Codec.struct({
|
||||
model: Codec.lenientOptional(ModelKeySchema),
|
||||
variant: Codec.lenientOptional(Codec.nullOr(Codec.string)),
|
||||
})
|
||||
const StateSchema = Schema.Struct({
|
||||
const StateSchema = Codec.struct({
|
||||
...ChoiceSchema.fields,
|
||||
agent: Persistence.optional(Schema.String),
|
||||
choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)),
|
||||
agent: Codec.lenientOptional(Codec.string),
|
||||
choices: Codec.lenientOptional(Codec.record(ChoiceSchema)),
|
||||
})
|
||||
type State = typeof StateSchema.Type
|
||||
|
||||
const SessionsSchema = Schema.Record(
|
||||
Schema.String,
|
||||
Schema.mutableKey(Persistence.fallback(Schema.UndefinedOr(StateSchema), () => undefined)),
|
||||
const SessionsSchema = Codec.record(Codec.fallback(Codec.undefinedOr(StateSchema), () => undefined))
|
||||
|
||||
const Current = Codec.struct({ session: SessionsSchema })
|
||||
const StoredSelection = Codec.struct(
|
||||
{
|
||||
session: Codec.lenientOptional(Codec.record(Codec.unknown)),
|
||||
pick: Codec.lenientOptional(Codec.record(Codec.unknown)),
|
||||
},
|
||||
{ preserve: true },
|
||||
)
|
||||
|
||||
const Current = Persistence.struct({ session: SessionsSchema })
|
||||
|
||||
export const ModelSelectionSchema = Persistence.migrate(
|
||||
export const ModelSelectionSchema = Codec.migrate(
|
||||
Current,
|
||||
Schema.Struct({
|
||||
session: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
pick: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((value) => ({
|
||||
session:
|
||||
value.session ??
|
||||
Object.fromEntries(Object.entries(value.pick ?? {}).filter(([key]) => key !== WORKSPACE_KEY)),
|
||||
})),
|
||||
encode: SchemaGetter.transform((value) => value),
|
||||
Codec.transform(StoredSelection, {
|
||||
decode: (value) => ({
|
||||
...value,
|
||||
session:
|
||||
value.session ?? Object.fromEntries(Object.entries(value.pick ?? {}).filter(([key]) => key !== WORKSPACE_KEY)),
|
||||
}),
|
||||
),
|
||||
encode: (value) => value,
|
||||
}),
|
||||
)
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
|
||||
@@ -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 }) {
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,21 +36,19 @@ describe("persisted consumer schemas", () => {
|
||||
})
|
||||
|
||||
test("model selection migrates legacy picks and omits workspace state", () => {
|
||||
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))
|
||||
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), input)
|
||||
expect(decode({})).toEqual({ session: {} })
|
||||
const state = decode({ pick: { __workspace__: { agent: "plan" }, session1: { agent: "build" } } })
|
||||
expect(state.session.session1?.agent).toBe("build")
|
||||
expect(state.session.__workspace__).toBeUndefined()
|
||||
const encoded = Schema.encodeSync(
|
||||
Schema.fromJsonString(Persistence.withInitial(ModelSelectionSchema, { session: {} })),
|
||||
)(state)
|
||||
const encoded = Codec.fromJsonString(Codec.withInitial(ModelSelectionSchema, { session: {} })).encode(state)
|
||||
expect(JSON.parse(encoded)).toEqual({ session: { session1: { agent: "build" } } })
|
||||
expect(decode(JSON.parse(encoded))).toEqual(state)
|
||||
})
|
||||
|
||||
test("current model selections take precedence over legacy picks", () => {
|
||||
expect(
|
||||
Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))({
|
||||
Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), {
|
||||
session: {},
|
||||
pick: { session1: { agent: "plan" } },
|
||||
}),
|
||||
@@ -58,7 +56,7 @@ describe("persisted consumer schemas", () => {
|
||||
})
|
||||
|
||||
test("model selection validates nested model keys and preserves explicit null variants", () => {
|
||||
const state = Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))({
|
||||
const state = Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), {
|
||||
session: {
|
||||
good: { agent: "build", model: { providerID: "provider", modelID: "model", variant: "high" }, variant: null },
|
||||
partial: { agent: "plan", model: { providerID: "provider", modelID: 42 }, variant: false },
|
||||
@@ -104,7 +102,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 +111,5 @@ describe("persisted consumer schemas", () => {
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Effect, Fiber } from "effect"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { SshConfig, SshItem, SshPlatform } from "./types"
|
||||
@@ -24,7 +23,8 @@ export function createSshController(input: {
|
||||
| undefined
|
||||
>
|
||||
>({})
|
||||
const tasks = new Map<string, Fiber.Fiber<void>>()
|
||||
// One in-flight request per server; cancelling settles it immediately and ignores its outcome.
|
||||
const tasks = new Map<string, { cancelled: boolean }>()
|
||||
const item = (id: string) => input.items().find((item) => item.config.id === id)
|
||||
const settle = (id: string) => {
|
||||
const attempt = attempts[id]
|
||||
@@ -33,31 +33,32 @@ export function createSshController(input: {
|
||||
setAttempts(id, { active: false, onConnected: undefined })
|
||||
if (item(id)?.stage === "ready" && onConnected) queueMicrotask(onConnected)
|
||||
}
|
||||
const run = (id: string, effect: Effect.Effect<unknown, unknown>) => {
|
||||
setAttempts(id, { submitting: true, error: false })
|
||||
tasks.set(
|
||||
id,
|
||||
Effect.runFork(
|
||||
effect.pipe(
|
||||
Effect.asVoid,
|
||||
Effect.catch(() =>
|
||||
Effect.sync(() => {
|
||||
setAttempts(id, "error", true)
|
||||
if (!attempts[id]?.prompted) input.error()
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
tasks.delete(id)
|
||||
setAttempts(id, "submitting", false)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
onCleanup(() => {
|
||||
Effect.runFork(Effect.forEach([...tasks.values()], Fiber.interrupt, { discard: true }))
|
||||
const finish = (id: string, task: { cancelled: boolean }) => {
|
||||
if (task.cancelled) return
|
||||
task.cancelled = true
|
||||
if (tasks.get(id) === task) tasks.delete(id)
|
||||
setAttempts(id, "submitting", false)
|
||||
}
|
||||
const interrupt = (id: string) => {
|
||||
const task = tasks.get(id)
|
||||
if (task) finish(id, task)
|
||||
}
|
||||
const run = (id: string, work: () => Promise<unknown>) => {
|
||||
setAttempts(id, { submitting: true, error: false })
|
||||
const task = { cancelled: false }
|
||||
tasks.set(id, task)
|
||||
work().then(
|
||||
() => finish(id, task),
|
||||
() => {
|
||||
if (task.cancelled) return
|
||||
setAttempts(id, "error", true)
|
||||
if (!attempts[id]?.prompted) input.error()
|
||||
finish(id, task)
|
||||
},
|
||||
)
|
||||
}
|
||||
onCleanup(() => {
|
||||
for (const id of [...tasks.keys()]) interrupt(id)
|
||||
})
|
||||
createEffect(() => {
|
||||
for (const item of input.items()) {
|
||||
@@ -114,40 +115,32 @@ export function createSshController(input: {
|
||||
error: false,
|
||||
onConnected: options?.onConnected ?? (options?.replace ? attempts[config.id]?.onConnected : undefined),
|
||||
})
|
||||
run(
|
||||
config.id,
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.tryPromise(() => api.start({ ...config, replace: options?.replace }))
|
||||
// Observe admission before treating an older disconnected snapshot as cancellation.
|
||||
yield* Effect.tryPromise(input.refresh)
|
||||
}),
|
||||
)
|
||||
run(config.id, async () => {
|
||||
await api.start({ ...config, replace: options?.replace })
|
||||
// Observe admission before treating an older disconnected snapshot as cancellation.
|
||||
await input.refresh()
|
||||
})
|
||||
},
|
||||
respond: (id: string, prompt: string, value: string) => {
|
||||
const api = input.api
|
||||
if (!api || item(id)?.prompt?.id !== prompt || attempts[id]?.submitting) return
|
||||
if (attempts[id]?.answered === prompt && !attempts[id]?.error) return
|
||||
setAttempts(id, "answered", prompt)
|
||||
run(
|
||||
id,
|
||||
Effect.tryPromise(() => api.respond(id, prompt, value)),
|
||||
)
|
||||
run(id, () => api.respond(id, prompt, value))
|
||||
},
|
||||
cancel: (id: string) => {
|
||||
const task = tasks.get(id)
|
||||
const api = input.api
|
||||
Effect.runFork(
|
||||
Effect.gen(function* () {
|
||||
if (task) yield* Fiber.interrupt(task)
|
||||
setAttempts(id, undefined)
|
||||
if (!api) return
|
||||
yield* Effect.tryPromise(() => api.cancel(id))
|
||||
if (!item(id)?.saved) yield* Effect.tryPromise(() => api.forget(id))
|
||||
}).pipe(Effect.ignore),
|
||||
)
|
||||
cancel: (id: string) => {
|
||||
const api = input.api
|
||||
interrupt(id)
|
||||
setAttempts(id, undefined)
|
||||
if (!api) return
|
||||
void api
|
||||
.cancel(id)
|
||||
.then(() => (item(id)?.saved ? undefined : api.forget(id)))
|
||||
.catch(() => undefined)
|
||||
},
|
||||
restore: (config: SshConfig) => input.api?.start({ ...config, background: true }),
|
||||
disconnect: (id: string) => input.api?.disconnect(id),
|
||||
forget: (id: string) => input.api?.forget(id),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
// The SSH state contract between the desktop main process and its renderer. Only the main process
|
||||
// needs the schemas (its RPC server validates with them); the renderer imports the types from ./types.
|
||||
|
||||
export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
|
||||
export type SshConfig = typeof SshConfig.Type
|
||||
export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
|
||||
export type SshHttp = typeof SshHttp.Type
|
||||
export const SshStage = Schema.Literals([
|
||||
"disconnected",
|
||||
"connecting",
|
||||
"checking",
|
||||
"downloading",
|
||||
"uploading",
|
||||
"starting",
|
||||
"ready",
|
||||
"authentication",
|
||||
"incompatible",
|
||||
"failed",
|
||||
])
|
||||
export const SshPrompt = Schema.Struct({
|
||||
id: Schema.String,
|
||||
text: Schema.String,
|
||||
confirm: Schema.Boolean,
|
||||
})
|
||||
export const SshItem = Schema.Struct({
|
||||
config: SshConfig,
|
||||
saved: Schema.Boolean,
|
||||
destination: Schema.optional(Schema.String),
|
||||
stage: SshStage,
|
||||
http: Schema.optional(SshHttp),
|
||||
prompt: Schema.optional(SshPrompt),
|
||||
authenticatingElsewhere: Schema.optional(Schema.Boolean),
|
||||
detail: Schema.String,
|
||||
error: Schema.optional(
|
||||
Schema.Literals([
|
||||
"connection",
|
||||
"input",
|
||||
"platform",
|
||||
"version",
|
||||
"install",
|
||||
"service",
|
||||
"host-key",
|
||||
"ssh-missing",
|
||||
"unpublished",
|
||||
]),
|
||||
),
|
||||
})
|
||||
export type SshItem = typeof SshItem.Type
|
||||
export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
|
||||
export type SshState = typeof SshState.Type
|
||||
export const SshStart = Schema.Struct({
|
||||
id: Schema.String,
|
||||
target: Schema.String,
|
||||
name: Schema.String,
|
||||
replace: Schema.optional(Schema.Boolean),
|
||||
background: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export type SshStart = typeof SshStart.Type
|
||||
|
||||
@@ -1,63 +1,9 @@
|
||||
import { Schema } from "effect"
|
||||
export { sshHostname, sshName } from "./name"
|
||||
export { isSshConnecting } from "./status"
|
||||
|
||||
export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
|
||||
export type SshConfig = typeof SshConfig.Type
|
||||
export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
|
||||
export type SshHttp = typeof SshHttp.Type
|
||||
export const SshStage = Schema.Literals([
|
||||
"disconnected",
|
||||
"connecting",
|
||||
"checking",
|
||||
"downloading",
|
||||
"uploading",
|
||||
"starting",
|
||||
"ready",
|
||||
"authentication",
|
||||
"incompatible",
|
||||
"failed",
|
||||
])
|
||||
export const SshPrompt = Schema.Struct({
|
||||
id: Schema.String,
|
||||
text: Schema.String,
|
||||
confirm: Schema.Boolean,
|
||||
})
|
||||
export const SshItem = Schema.Struct({
|
||||
config: SshConfig,
|
||||
saved: Schema.Boolean,
|
||||
destination: Schema.optional(Schema.String),
|
||||
stage: SshStage,
|
||||
http: Schema.optional(SshHttp),
|
||||
prompt: Schema.optional(SshPrompt),
|
||||
authenticatingElsewhere: Schema.optional(Schema.Boolean),
|
||||
detail: Schema.String,
|
||||
error: Schema.optional(
|
||||
Schema.Literals([
|
||||
"connection",
|
||||
"input",
|
||||
"platform",
|
||||
"version",
|
||||
"install",
|
||||
"service",
|
||||
"host-key",
|
||||
"ssh-missing",
|
||||
"unpublished",
|
||||
]),
|
||||
),
|
||||
})
|
||||
export type SshItem = typeof SshItem.Type
|
||||
export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
|
||||
export type SshState = typeof SshState.Type
|
||||
export const SshStart = Schema.Struct({
|
||||
id: Schema.String,
|
||||
target: Schema.String,
|
||||
name: Schema.String,
|
||||
replace: Schema.optional(Schema.Boolean),
|
||||
background: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export type SshStart = typeof SshStart.Type
|
||||
|
||||
export { sshHostname, sshName } from "./name"
|
||||
export { isSshConnecting } from "./status"
|
||||
import type { SshConfig, SshHttp, SshItem, SshStart, SshState } from "./schema"
|
||||
|
||||
export type { SshConfig, SshHttp, SshItem, SshStart, SshState }
|
||||
|
||||
export type SshPlatform = {
|
||||
getState(): Promise<SshState>
|
||||
subscribe(callback: (state: SshState) => void): () => void
|
||||
@@ -70,3 +16,5 @@ export type SshPlatform = {
|
||||
forget(id: string): Promise<void>
|
||||
openConfig(): Promise<void>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string, Promise<boolean>>()
|
||||
@@ -242,3 +241,4 @@ function checkAppExists(platform: ReturnType<typeof usePlatform>, app: string) {
|
||||
appExistence.set(app, request)
|
||||
return request
|
||||
}
|
||||
|
||||
|
||||
@@ -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<number, number>(
|
||||
(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<typeof createReviewPanelState>
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { Persist } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
import { Schema } from "effect"
|
||||
|
||||
let getWorkspaceTerminalCacheKey: typeof import("./context").getWorkspaceTerminalCacheKey
|
||||
let clearWorkspaceTerminals: typeof import("./context").clearWorkspaceTerminals
|
||||
@@ -21,10 +20,10 @@ beforeAll(async () => {
|
||||
const mod = await import("./context")
|
||||
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
|
||||
clearWorkspaceTerminals = mod.clearWorkspaceTerminals
|
||||
const schema = Persistence.withInitial(mod.TerminalState, { all: [] })
|
||||
decodeTerminalState = Schema.decodeUnknownSync(schema)
|
||||
const schema = Codec.withInitial(mod.TerminalState, { all: [] })
|
||||
decodeTerminalState = ((input: unknown) => Codec.decodeOrThrow(schema, input))
|
||||
roundTripTerminalState = (value) =>
|
||||
Schema.decodeUnknownSync(schema)(Schema.encodeSync(schema)(Schema.decodeUnknownSync(schema)(value)))
|
||||
Codec.decodeOrThrow(schema, schema.encode(Codec.decodeOrThrow(schema, value)))
|
||||
})
|
||||
|
||||
describe("getWorkspaceTerminalCacheKey", () => {
|
||||
@@ -141,3 +140,4 @@ describe("TerminalState", () => {
|
||||
expect(roundTripTerminalState(value)).toEqual(value)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,18 +8,20 @@ import { base64Encode } from "@opencode/util/encode"
|
||||
import { defaultTitle, titleNumber } from "./title"
|
||||
import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage"
|
||||
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
|
||||
const PTY = Persistence.struct({
|
||||
id: Schema.NonEmptyString,
|
||||
title: Persistence.fallback(Schema.String, () => ""),
|
||||
titleNumber: Persistence.fallback(Schema.Finite, () => 0),
|
||||
rows: Persistence.optional(Schema.Finite),
|
||||
cols: Persistence.optional(Schema.Finite),
|
||||
buffer: Persistence.optional(Schema.String),
|
||||
scrollY: Persistence.optional(Schema.Finite),
|
||||
cursor: Persistence.optional(Schema.Finite),
|
||||
const PTY = Codec.struct({
|
||||
id: Codec.make<string, string>(
|
||||
(v) => (typeof v === "string" && v.length > 0 ? v : Codec.INVALID),
|
||||
(v) => v,
|
||||
),
|
||||
title: Codec.fallback(Codec.string, () => ""),
|
||||
titleNumber: Codec.fallback(Codec.number, () => 0),
|
||||
rows: Codec.lenientOptional(Codec.number),
|
||||
cols: Codec.lenientOptional(Codec.number),
|
||||
buffer: Codec.lenientOptional(Codec.string),
|
||||
scrollY: Codec.lenientOptional(Codec.number),
|
||||
cursor: Codec.lenientOptional(Codec.number),
|
||||
})
|
||||
|
||||
export type LocalPTY = typeof PTY.Type
|
||||
@@ -31,28 +33,26 @@ function numberFromTitle(title: string) {
|
||||
return titleNumber(title, MAX_TERMINAL_SESSIONS)
|
||||
}
|
||||
|
||||
const State = Persistence.struct({
|
||||
active: Persistence.optional(Schema.String),
|
||||
all: Persistence.array(PTY),
|
||||
const State = Codec.struct({
|
||||
active: Codec.lenientOptional(Codec.string),
|
||||
all: Codec.lenientArray(PTY),
|
||||
})
|
||||
|
||||
export const TerminalState = State.pipe(
|
||||
Schema.decodeTo(Schema.toType(State), {
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
const seen = new Set<string>()
|
||||
const all = value.all.flatMap((pty) => {
|
||||
if (seen.has(pty.id)) return []
|
||||
seen.add(pty.id)
|
||||
return [{ ...pty, titleNumber: pty.titleNumber > 0 ? pty.titleNumber : (numberFromTitle(pty.title) ?? 0) }]
|
||||
})
|
||||
return {
|
||||
active: value.active && seen.has(value.active) ? value.active : all[0]?.id,
|
||||
all,
|
||||
}
|
||||
}),
|
||||
encode: SchemaGetter.transform((value) => value),
|
||||
}),
|
||||
)
|
||||
export const TerminalState = Codec.transform(State, {
|
||||
decode: (value): typeof State.Type => {
|
||||
const seen = new Set<string>()
|
||||
const all = value.all.flatMap((pty) => {
|
||||
if (seen.has(pty.id)) return []
|
||||
seen.add(pty.id)
|
||||
return [{ ...pty, titleNumber: pty.titleNumber > 0 ? pty.titleNumber : (numberFromTitle(pty.title) ?? 0) }]
|
||||
})
|
||||
return {
|
||||
active: value.active && seen.has(value.active) ? value.active : all[0]?.id,
|
||||
all,
|
||||
}
|
||||
},
|
||||
encode: (value) => value,
|
||||
})
|
||||
|
||||
export function getWorkspaceTerminalCacheKey(dir: string, scope: ServerScope = ServerScope.local) {
|
||||
return ScopedKey.from(scope, dir, WORKSPACE_KEY)
|
||||
@@ -458,3 +458,5 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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() {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { NodeChildProcessSpawner } from "@effect/platform-node"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { app, shell, type WebContents } from "electron"
|
||||
import { homedir } from "node:os"
|
||||
import { SshConfig, type SshState } from "@opencode/app/ssh"
|
||||
import { SshConfig, type SshState } from "@opencode/app/ssh/schema"
|
||||
import { SshChanged } from "../../shared/ipc-rpc/events"
|
||||
import { DesktopCli } from "../service/desktop-cli"
|
||||
import { Shutdown } from "../lifecycle/shutdown"
|
||||
@@ -88,3 +88,4 @@ export const layer = Layer.effect(
|
||||
return service
|
||||
}),
|
||||
).pipe(Layer.provide(NodeChildProcessSpawner.layer), Layer.provide(FetchHttpClient.layer))
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
import { BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
|
||||
import { UpdaterStateSchema } from "./updater"
|
||||
import { WslServersEventSchema } from "./wsl"
|
||||
import { SshState } from "@opencode/app/ssh"
|
||||
import { SshState } from "@opencode/app/ssh/schema"
|
||||
|
||||
export class SshChanged extends Schema.TaggedClass<SshChanged>()("SshChanged", { state: SshState }) {}
|
||||
|
||||
@@ -64,3 +64,4 @@ export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
|
||||
|
||||
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
|
||||
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
import { SshHttp, SshStart, SshState } from "@opencode/app/ssh"
|
||||
import { SshHttp, SshStart, SshState } from "@opencode/app/ssh/schema"
|
||||
|
||||
export const SshRpcs = RpcGroup.make(
|
||||
Rpc.make("SshGetState", { success: SshState }),
|
||||
@@ -15,3 +15,4 @@ export const SshRpcs = RpcGroup.make(
|
||||
Rpc.make("SshForget", { payload: { id: Schema.String } }),
|
||||
Rpc.make("SshOpenConfig"),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user