mirror of
https://github.com/anomalyco/opencode.git
synced 2026-06-01 06:11:30 +00:00
480 lines
15 KiB
TypeScript
480 lines
15 KiB
TypeScript
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
|
|
import { showToast } from "@opencode-ai/ui/toast"
|
|
import { getFilename } from "@opencode-ai/core/util/path"
|
|
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
|
|
import { createStore, produce, reconcile } from "solid-js/store"
|
|
import { useLanguage } from "@/context/language"
|
|
import type { InitError } from "../pages/error"
|
|
import { useServerSDK } from "./server-sdk"
|
|
import {
|
|
bootstrapDirectory,
|
|
bootstrapGlobal,
|
|
clearProviderRev,
|
|
loadAgentsQuery,
|
|
loadGlobalConfigQuery,
|
|
loadPathQuery,
|
|
loadProjectsQuery,
|
|
loadProvidersQuery,
|
|
} from "./global-sync/bootstrap"
|
|
import { createChildStoreManager } from "./global-sync/child-store"
|
|
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
|
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
|
|
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, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
|
import { createRefCountMap } from "@/utils/refcount"
|
|
import { retry } from "@opencode-ai/core/util/retry"
|
|
|
|
type GlobalStore = {
|
|
ready: boolean
|
|
error?: InitError
|
|
path: Path
|
|
project: Project[]
|
|
session_todo: {
|
|
[sessionID: string]: Todo[]
|
|
}
|
|
provider: NormalizedProviderListResponse
|
|
provider_auth: ProviderAuthResponse
|
|
config: Config
|
|
reload: undefined | "pending" | "complete"
|
|
}
|
|
|
|
export const loadMcpQuery = (directory: string, sdk: OpencodeClient) =>
|
|
queryOptions({
|
|
queryKey: [directory, "mcp"] as const,
|
|
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
|
|
})
|
|
|
|
export const loadLspQuery = (directory: string, sdk: OpencodeClient) =>
|
|
queryOptions({
|
|
queryKey: [directory, "lsp"] as const,
|
|
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
|
|
})
|
|
|
|
function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
|
|
return {
|
|
globalConfig: () => loadGlobalConfigQuery(serverSDK()),
|
|
projects: () => loadProjectsQuery(serverSDK()),
|
|
providers: (directory: PathKey | null) =>
|
|
loadProvidersQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
|
|
path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
|
|
agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)),
|
|
mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)),
|
|
lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)),
|
|
sessions: (directory: PathKey) => ({ queryKey: [directory, "loadSessions"] as const }),
|
|
}
|
|
}
|
|
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
|
|
|
export function createServerSyncContext() {
|
|
const serverSDK = useServerSDK()
|
|
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.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: [],
|
|
session_todo: {},
|
|
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()
|
|
|
|
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: ["bootstrap"],
|
|
queryFn: async () => {
|
|
await bootstrapGlobal({
|
|
serverSDK: serverSDK.client,
|
|
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 setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
|
|
if (!sessionID) return
|
|
if (!todos) {
|
|
setGlobalStore(
|
|
"session_todo",
|
|
produce((draft) => {
|
|
delete draft[sessionID]
|
|
}),
|
|
)
|
|
return
|
|
}
|
|
setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
|
|
}
|
|
|
|
const paused = () => untrack(() => globalStore.reload) !== undefined
|
|
|
|
const queue = createRefreshQueue({
|
|
paused,
|
|
key: directoryKey,
|
|
bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }),
|
|
bootstrapInstance,
|
|
})
|
|
|
|
const children = createChildStoreManager({
|
|
owner,
|
|
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(key)
|
|
clearSessionPrefetchDirectory(key)
|
|
},
|
|
translate: language.t,
|
|
queryOptions: queryOptionsApi,
|
|
global: {
|
|
provider: globalStore.provider,
|
|
},
|
|
})
|
|
|
|
async function loadSessions(directory: string) {
|
|
const key = directoryKey(directory)
|
|
const pending = sessionLoads.get(key)
|
|
if (pending) return pending
|
|
|
|
children.pin(key)
|
|
const [store, setStore] = children.child(directory, { bootstrap: false })
|
|
const meta = sessionMeta.get(key)
|
|
if (meta && meta.limit >= store.limit) {
|
|
const next = trimSessions(store.session, {
|
|
limit: store.limit,
|
|
permission: store.permission,
|
|
})
|
|
if (next.length !== store.session.length) {
|
|
setStore("session", reconcile(next, { key: "id" }))
|
|
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
|
|
}
|
|
children.unpin(key)
|
|
return
|
|
}
|
|
|
|
const limit = Math.max(store.limit + 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 = store.limit
|
|
const childSessions = store.session.filter((s) => !!s.parentID)
|
|
const sessions = trimSessions([...nonArchived, ...childSessions], {
|
|
limit,
|
|
permission: store.permission,
|
|
})
|
|
batch(() => {
|
|
setStore(
|
|
"sessionTotal",
|
|
estimateRootSessionTotal({
|
|
count: nonArchived.length,
|
|
limit: x.limit,
|
|
limited: x.limited,
|
|
}),
|
|
)
|
|
setStore("session", reconcile(sessions, { key: "id" }))
|
|
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
|
|
})
|
|
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,
|
|
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,
|
|
})
|
|
})
|
|
|
|
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
|
|
|
|
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)) {
|
|
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: queue.push,
|
|
setSessionTodo,
|
|
vcsCache: children.vcsCache.get(key),
|
|
loadLsp: () => {
|
|
void queryClient.fetchQuery(queryOptionsApi.lsp(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: [null, "providers"] })
|
|
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[1] === "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,
|
|
todo: {
|
|
set: setSessionTodo,
|
|
},
|
|
}
|
|
}
|
|
|
|
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
|
|
name: "ServerSync",
|
|
init: () => {
|
|
const sync = createServerSyncContext()
|
|
|
|
return {
|
|
...sync,
|
|
createDirSyncContext: createRefCountMap(
|
|
(dir) => createDirSyncContext(dir, sync),
|
|
(dir) => sync.disableMcp(dir),
|
|
directoryKey,
|
|
),
|
|
}
|
|
},
|
|
})
|
|
|
|
export function useQueryOptions() {
|
|
return useServerSync().queryOptions
|
|
}
|