mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-24 13:25:01 +00:00
441 lines
13 KiB
TypeScript
441 lines
13 KiB
TypeScript
import type {
|
|
Config,
|
|
OpencodeClient,
|
|
Path,
|
|
Project,
|
|
ProviderAuthResponse,
|
|
ProviderListResponse,
|
|
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 { useGlobalSDK } from "./global-sdk"
|
|
import {
|
|
bootstrapDirectory,
|
|
bootstrapGlobal,
|
|
clearProviderRev,
|
|
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, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
|
|
import { createRefreshQueue } from "./global-sync/queue"
|
|
import { directoryKey } from "./global-sync/utils"
|
|
|
|
type GlobalStore = {
|
|
ready: boolean
|
|
error?: InitError
|
|
path: Path
|
|
project: Project[]
|
|
session_todo: {
|
|
[sessionID: string]: Todo[]
|
|
}
|
|
provider: ProviderListResponse
|
|
provider_auth: ProviderAuthResponse
|
|
config: Config
|
|
reload: undefined | "pending" | "complete"
|
|
}
|
|
|
|
export const loadSessionsQuery = (directory: string) =>
|
|
queryOptions<null>({ queryKey: [directory, "loadSessions"], queryFn: skipToken })
|
|
|
|
export const loadMcpQuery = (directory: string, sdk?: OpencodeClient) =>
|
|
queryOptions({
|
|
queryKey: [directory, "mcp"],
|
|
queryFn: sdk ? () => sdk.mcp.status().then((r) => r.data ?? {}) : skipToken,
|
|
})
|
|
|
|
export const loadLspQuery = (directory: string, sdk?: OpencodeClient) =>
|
|
queryOptions({
|
|
queryKey: [directory, "lsp"],
|
|
queryFn: sdk ? () => sdk.lsp.status().then((r) => r.data ?? []) : skipToken,
|
|
})
|
|
|
|
function createGlobalSync() {
|
|
const globalSDK = useGlobalSDK()
|
|
const language = useLanguage()
|
|
const owner = getOwner()
|
|
if (!owner) throw new Error("GlobalSync 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 [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
|
queries: [loadGlobalConfigQuery(), loadProvidersQuery(null), loadPathQuery(null), loadProjectsQuery()],
|
|
}))
|
|
|
|
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: [], 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({
|
|
globalSDK: globalSDK.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 sdkFor = (directory: string) => {
|
|
const key = directoryKey(directory)
|
|
const cached = sdkCache.get(key)
|
|
if (cached) return cached
|
|
const sdk = globalSDK.createClient({
|
|
directory,
|
|
throwOnError: true,
|
|
})
|
|
sdkCache.set(key, sdk)
|
|
return sdk
|
|
}
|
|
|
|
const children = createChildStoreManager({
|
|
owner,
|
|
isBooting: (directory) => booting.has(directory),
|
|
isLoadingSessions: (directory) => sessionLoads.has(directory),
|
|
onBootstrap: (directory) => {
|
|
void bootstrapInstance(directory)
|
|
},
|
|
onDispose: (directory) => {
|
|
const key = directoryKey(directory)
|
|
queue.clear(key)
|
|
sessionMeta.delete(key)
|
|
sdkCache.delete(key)
|
|
clearProviderRev(key)
|
|
clearSessionPrefetchDirectory(key)
|
|
},
|
|
translate: language.t,
|
|
getSdk: sdkFor,
|
|
})
|
|
|
|
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({
|
|
...loadSessionsQuery(key),
|
|
queryFn: () =>
|
|
loadRootSessionsWithFallback({
|
|
directory,
|
|
limit,
|
|
list: (query) => globalSDK.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,
|
|
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 = globalSDK.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(loadLspQuery(key, sdkFor(directory)))
|
|
},
|
|
})
|
|
})
|
|
|
|
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 globalSDK.event.start()
|
|
}, 0)
|
|
})
|
|
} else {
|
|
eventTimer = setTimeout(() => {
|
|
eventTimer = undefined
|
|
void globalSDK.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) => globalSDK.client.global.config.update({ config }),
|
|
onSuccess: () => bootstrap.refetch(),
|
|
}))
|
|
|
|
return {
|
|
data: globalStore,
|
|
set,
|
|
get ready() {
|
|
return globalStore.ready
|
|
},
|
|
get error() {
|
|
return globalStore.error
|
|
},
|
|
child: children.child,
|
|
peek: children.peek,
|
|
// bootstrap,
|
|
updateConfig: updateConfigMutation.mutateAsync,
|
|
project: projectApi,
|
|
todo: {
|
|
set: setSessionTodo,
|
|
},
|
|
}
|
|
}
|
|
|
|
const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
|
|
|
|
export function GlobalSyncProvider(props: ParentProps) {
|
|
const value = createGlobalSync()
|
|
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
|
}
|
|
|
|
export function useGlobalSync() {
|
|
const context = useContext(GlobalSyncContext)
|
|
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
|
|
return context
|
|
}
|