| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560 |
- import type {
- Config,
- McpResource,
- OpencodeClient,
- Path,
- Project,
- ProviderAuthResponse,
- } from "@opencode-ai/sdk/v2/client"
- import { showToast } from "@/utils/toast"
- import { getFilename } from "@opencode-ai/core/util/path"
- import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
- import { createStore, produce, reconcile } from "solid-js/store"
- import { useLanguage } from "@/context/language"
- import type { InitError } from "../pages/error"
- import { ServerSDK } from "./server-sdk"
- import {
- bootstrapDirectory,
- bootstrapGlobal,
- clearProviderRev,
- loadAgentsQuery,
- loadGlobalConfigQuery,
- loadPathQuery,
- loadProjectsQuery,
- loadProvidersQuery,
- loadReferencesQuery,
- } from "./global-sync/bootstrap"
- import { createChildStoreManager } from "./global-sync/child-store"
- import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
- import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
- import { trimSessions } from "./global-sync/session-trim"
- import type { ProjectMeta } from "./global-sync/types"
- import { SESSION_RECENT_LIMIT } from "./global-sync/types"
- import { formatServerError } from "@/utils/server-errors"
- import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
- import { createRefreshQueue } from "./global-sync/queue"
- import { directoryKey } from "./global-sync/utils"
- import { PathKey } from "@/utils/path-key"
- import { createDirSyncContext } from "./directory-sync"
- import { createSimpleContext } from "@opencode-ai/ui/context"
- import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
- import { createRefCountMap } from "@/utils/refcount"
- import { useGlobal } from "./global"
- import { ServerConnection, useServer } from "./server"
- import { retry } from "@opencode-ai/core/util/retry"
- import type { ServerScope } from "@/utils/server-scope"
- import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
- import { persisted } from "@/utils/persist"
- import { toggleMcp } from "./global-sync/mcp"
- import { createServerSession } from "./server-session"
- type GlobalStore = {
- ready: boolean
- error?: InitError
- path: Path
- project: Project[]
- provider: NormalizedProviderListResponse
- provider_auth: ProviderAuthResponse
- config: Config
- reload: undefined | "pending" | "complete"
- }
- export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
- queryOptions({
- queryKey: [scope, directory, "mcp"] as const,
- queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
- })
- export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
- queryOptions<Record<string, McpResource>>({
- queryKey: [scope, directory, "mcpResources"] as const,
- queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
- placeholderData: {},
- })
- export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
- queryOptions({
- queryKey: [scope, directory, "lsp"] as const,
- queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
- })
- function makeQueryOptionsApi(
- scope: ServerScope,
- serverSDK: () => OpencodeClient,
- sdkFor: (dir: PathKey) => OpencodeClient,
- ) {
- return {
- globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
- projects: () => loadProjectsQuery(scope, serverSDK()),
- providers: (directory: PathKey | null) =>
- loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
- path: (directory: PathKey | null) =>
- loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
- agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
- references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
- mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
- mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
- lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
- sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
- }
- }
- export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
- export function createServerSyncContextInner(serverSDK: ServerSDK) {
- const language = useLanguage()
- const owner = getOwner()
- if (!owner) throw new Error("ServerSync must be created within owner")
- const sdkCache = new Map<string, OpencodeClient>()
- const booting = new Map<string, Promise<void>>()
- const sessionLoads = new Map<string, Promise<void>>()
- const sessionMeta = new Map<string, { limit: number }>()
- const sdkFor = (directory: string) => {
- const key = directoryKey(directory)
- const cached = sdkCache.get(key)
- if (cached) return cached
- const sdk = serverSDK.createClient({
- directory,
- throwOnError: true,
- })
- sdkCache.set(key, sdk)
- return sdk
- }
- const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
- const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
- queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
- }))
- const [globalStore, setGlobalStore] = createStore<GlobalStore>({
- get ready() {
- return !bootstrap.isPending
- },
- project: [],
- provider_auth: {},
- get path() {
- const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" }
- if (pathQuery.isLoading) return EMPTY
- return pathQuery.data ?? EMPTY
- },
- get provider() {
- const EMPTY = { all: new Map(), connected: [], default: {} }
- if (providerQuery.isLoading) return EMPTY
- return providerQuery.data ?? EMPTY
- },
- get config() {
- if (configQuery.isLoading) return {}
- return configQuery.data ?? {}
- },
- get reload() {
- return updateConfigMutation.isPending ? "pending" : undefined
- },
- })
- const queryClient = useQueryClient()
- const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
- let bootedAt = 0
- let bootingRoot = false
- let eventFrame: number | undefined
- let eventTimer: ReturnType<typeof setTimeout> | undefined
- onCleanup(() => {
- if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
- if (eventTimer !== undefined) clearTimeout(eventTimer)
- })
- const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
- setGlobalStore("project", next)
- }
- const setBootStore = ((...input: unknown[]) => {
- if (input[0] === "project" && Array.isArray(input[1])) {
- setProjects(input[1] as Project[])
- return input[1]
- }
- return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
- }) as typeof setGlobalStore
- const bootstrap = useQuery(() => ({
- queryKey: [serverSDK.scope, "bootstrap"],
- queryFn: async () => {
- await bootstrapGlobal({
- serverSDK: serverSDK.client,
- scope: serverSDK.scope,
- requestFailedTitle: language.t("common.requestFailed"),
- translate: language.t,
- formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
- setGlobalStore: setBootStore,
- queryClient,
- })
- bootedAt = Date.now()
- return bootedAt
- },
- }))
- const set = ((...input: unknown[]) => {
- if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
- setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
- return input[1]
- }
- return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
- }) as typeof setGlobalStore
- const paused = () => untrack(() => globalStore.reload) !== undefined
- const queue = createRefreshQueue({
- paused,
- key: directoryKey,
- bootstrap: () => queryClient.fetchQuery({ queryKey: [serverSDK.scope, "bootstrap"] }),
- bootstrapInstance,
- })
- const session = createServerSession(serverSDK.client)
- const children = createChildStoreManager({
- owner,
- scope: serverSDK.scope,
- persist: persisted,
- isBooting: (directory) => booting.has(directory),
- isLoadingSessions: (directory) => sessionLoads.has(directory),
- onBootstrap: (directory) => {
- void bootstrapInstance(directory)
- },
- onMcp: (directory, setStore) => {
- void retry(() =>
- sdkFor(directory)
- .command.list()
- .then((x) => setStore("command", x.data ?? [])),
- ).catch((err) => {
- showToast({
- variant: "error",
- title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
- description: formatServerError(err, language.t),
- })
- })
- },
- onDispose: (directory) => {
- const key = directoryKey(directory)
- queue.clear(key)
- sessionMeta.delete(key)
- sdkCache.delete(key)
- clearProviderRev(serverSDK.scope, key)
- },
- translate: language.t,
- queryOptions: queryOptionsApi,
- global: {
- provider: globalStore.provider,
- },
- })
- async function loadSessions(directory: string, options?: { limit?: number }) {
- const key = directoryKey(directory)
- const pending = sessionLoads.get(key)
- if (pending) {
- await pending
- return loadSessions(directory, options)
- }
- children.pin(key)
- const [store, setStore] = children.child(directory, { bootstrap: false })
- const meta = sessionMeta.get(key)
- const retainedLimit = Math.max(store.limit, options?.limit ?? 0, meta?.limit ?? 0)
- if (meta && meta.limit >= retainedLimit) {
- const next = trimSessions(store.session, {
- limit: retainedLimit,
- permission: session.data.permission,
- })
- if (next.length !== store.session.length) {
- setStore("session", reconcile(next, { key: "id" }))
- }
- children.unpin(key)
- return
- }
- const limit = Math.max(retainedLimit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
- const promise = queryClient
- .fetchQuery({
- ...queryOptionsApi.sessions(key),
- queryFn: () =>
- loadRootSessionsWithFallback({
- directory,
- limit,
- list: (query) => serverSDK.client.session.list(query),
- })
- .then((x) => {
- const nonArchived = (x.data ?? [])
- .filter((s) => !!s?.id)
- .filter((s) => !s.time?.archived)
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
- const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0)
- const childSessions = store.session.filter((s) => !!s.parentID)
- const next = trimSessions([...nonArchived, ...childSessions], {
- limit,
- permission: session.data.permission,
- })
- batch(() => {
- next.forEach(session.remember)
- setStore(
- "sessionTotal",
- estimateRootSessionTotal({
- count: nonArchived.length,
- limit: x.limit,
- limited: x.limited,
- }),
- )
- setStore("session", reconcile(next, { key: "id" }))
- })
- sessionMeta.set(key, { limit })
- })
- .catch((err) => {
- console.error("Failed to load sessions", err)
- const project = getFilename(directory)
- showToast({
- variant: "error",
- title: language.t("toast.session.listFailed.title", { project }),
- description: formatServerError(err, language.t),
- })
- })
- .then(() => null),
- })
- .then(() => {})
- sessionLoads.set(key, promise)
- void promise.finally(() => {
- sessionLoads.delete(key)
- children.unpin(key)
- })
- return promise
- }
- async function bootstrapInstance(directory: string) {
- const key = directoryKey(directory)
- if (!key) return
- const pending = booting.get(key)
- if (pending) return pending
- children.pin(key)
- const promise = Promise.resolve().then(async () => {
- const child = children.ensureChild(directory)
- const cache = children.vcsCache.get(key)
- if (!cache) return
- const sdk = sdkFor(directory)
- await bootstrapDirectory({
- directory,
- scope: serverSDK.scope,
- mcp: children.mcp(key),
- global: {
- config: globalStore.config,
- path: globalStore.path,
- project: globalStore.project,
- provider: globalStore.provider,
- },
- sdk,
- store: child[0],
- setStore: child[1],
- vcsCache: cache,
- loadSessions,
- translate: language.t,
- queryClient,
- session,
- })
- })
- booting.set(key, promise)
- void promise.finally(() => {
- booting.delete(key)
- children.unpin(key)
- })
- return promise
- }
- const unsub = serverSDK.event.listen((e) => {
- const directory = e.name
- const key = directoryKey(directory)
- const event = e.details
- const recent = bootingRoot || Date.now() - bootedAt < 1500
- session.apply(event)
- if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
- homeSessions.apply(event)
- }
- homeSessions.refresh(event.type)
- if (directory === "global") {
- applyGlobalEvent({
- event,
- project: globalStore.project,
- refresh: () => {
- if (recent) return
- bootstrap.refetch()
- },
- setGlobalProject: setProjects,
- })
- if (event.type === "server.connected" || event.type === "global.disposed") {
- if (recent) return
- for (const directory of Object.keys(children.children)) {
- if (!children.active(directory)) continue
- queue.push(directory)
- }
- }
- return
- }
- const existing = children.children[key]
- if (!existing) return
- children.mark(key)
- const [store, setStore] = existing
- applyDirectoryEvent({
- event,
- directory,
- store,
- setStore,
- push: (directory) => {
- if (children.active(directory)) queue.push(directory)
- },
- retainedLimit: sessionMeta.get(key)?.limit,
- sessionContent: false,
- permission: session.data.permission,
- vcsCache: children.vcsCache.get(key),
- loadLsp: () => {
- if (!children.active(key)) return
- void queryClient.fetchQuery(queryOptionsApi.lsp(key))
- },
- loadReferences: () => {
- if (!children.active(key)) return
- void queryClient.fetchQuery(queryOptionsApi.references(key))
- },
- })
- })
- onCleanup(unsub)
- onCleanup(() => {
- queue.dispose()
- })
- onCleanup(() => {
- for (const directory of Object.keys(children.children)) {
- children.disposeDirectory(directoryKey(directory))
- }
- })
- onMount(() => {
- if (typeof requestAnimationFrame === "function") {
- eventFrame = requestAnimationFrame(() => {
- eventFrame = undefined
- eventTimer = setTimeout(() => {
- eventTimer = undefined
- void serverSDK.event.start()
- }, 0)
- })
- } else {
- eventTimer = setTimeout(() => {
- eventTimer = undefined
- void serverSDK.event.start()
- }, 0)
- }
- })
- const projectApi = {
- loadSessions,
- meta(directory: string, patch: ProjectMeta) {
- children.projectMeta(directory, patch)
- },
- icon(directory: string, value: string | undefined) {
- children.projectIcon(directory, value)
- },
- }
- const updateConfigMutation = useMutation(() => ({
- mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
- onSuccess: () => {
- bootstrap.refetch()
- // Invalidate all provider queries so newly configured custom providers
- // appear immediately in the available provider list across all directories.
- queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] })
- queryClient.invalidateQueries({
- predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
- })
- },
- }))
- return {
- data: globalStore,
- set,
- get ready() {
- return globalStore.ready
- },
- get error() {
- return globalStore.error
- },
- child: children.child,
- peek: children.peek,
- disableMcp: children.disableMcp,
- queryOptions: queryOptionsApi,
- // bootstrap,
- updateConfig: updateConfigMutation.mutateAsync,
- project: projectApi,
- session,
- homeSessions,
- mcp: {
- toggle: async (directory: string, name: string) => {
- const key = directoryKey(directory)
- const sdk = sdkFor(key)
- const status = children.child(key, { bootstrap: false })[0].mcp[name].status
- await toggleMcp({
- status,
- connect: async () => {
- await sdk.mcp.connect({ name })
- },
- disconnect: async () => {
- await sdk.mcp.disconnect({ name })
- },
- authenticate: async () => {
- await sdk.mcp.auth.authenticate({ name })
- },
- refresh: async () => {
- await queryClient.refetchQueries(queryOptionsApi.mcp(key))
- await queryClient.refetchQueries(queryOptionsApi.mcpResources(key))
- },
- })
- },
- },
- }
- }
- export function createServerSyncContext(serverSDK: ServerSDK) {
- const inner = createServerSyncContextInner(serverSDK)
- return Object.assign(inner, {
- ensureDirSyncContext: createRefCountMap(
- (dir) => createDirSyncContext(dir, inner, serverSDK),
- (dir) => inner.disableMcp(dir),
- directoryKey,
- ),
- })
- }
- export type ServerSync = ReturnType<typeof createServerSyncContext>
- export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
- name: "ServerSync",
- // Returns an accessor so the resolved server can change reactively without
- // re-instantiating the subtree (mirrors useServerSDK).
- init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
- const global = useGlobal()
- const language = useLanguage()
- const server = useServer()
- return createMemo<ServerSync>(() => {
- const conn = props.server?.() ?? server.current
- if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
- return global.ensureServerCtx(conn).sync
- })
- },
- })
- export function useQueryOptions() {
- const sync = useServerSync()
- return createMemo(() => sync().queryOptions)
- }
|