391 lines
11 KiB
TypeScript
391 lines
11 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/util/path"
|
|
import {
|
|
createContext,
|
|
createEffect,
|
|
getOwner,
|
|
Match,
|
|
onCleanup,
|
|
onMount,
|
|
type ParentProps,
|
|
Switch,
|
|
untrack,
|
|
useContext,
|
|
} from "solid-js"
|
|
import { createStore, produce, reconcile } from "solid-js/store"
|
|
import { useLanguage } from "@/context/language"
|
|
import { Persist, persisted } from "@/utils/persist"
|
|
import type { InitError } from "../pages/error"
|
|
import { useGlobalSDK } from "./global-sdk"
|
|
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
|
import { createChildStoreManager } from "./global-sync/child-store"
|
|
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
|
import { createRefreshQueue } from "./global-sync/queue"
|
|
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 { sanitizeProject } from "./global-sync/utils"
|
|
import { usePlatform } from "./platform"
|
|
import { formatServerError } from "@/utils/server-errors"
|
|
|
|
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"
|
|
}
|
|
|
|
function createGlobalSync() {
|
|
const globalSDK = useGlobalSDK()
|
|
const platform = usePlatform()
|
|
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 [projectCache, setProjectCache, , projectCacheReady] = persisted(
|
|
Persist.global("globalSync.project", ["globalSync.project.v1"]),
|
|
createStore({ value: [] as Project[] }),
|
|
)
|
|
|
|
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
|
ready: false,
|
|
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
|
project: projectCache.value,
|
|
session_todo: {},
|
|
provider: { all: [], connected: [], default: {} },
|
|
provider_auth: {},
|
|
config: {},
|
|
reload: undefined,
|
|
})
|
|
|
|
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,
|
|
bootstrap,
|
|
bootstrapInstance,
|
|
})
|
|
|
|
const children = createChildStoreManager({
|
|
owner,
|
|
isBooting: (directory) => booting.has(directory),
|
|
isLoadingSessions: (directory) => sessionLoads.has(directory),
|
|
onBootstrap: (directory) => {
|
|
void bootstrapInstance(directory)
|
|
},
|
|
onDispose: (directory) => {
|
|
queue.clear(directory)
|
|
sessionMeta.delete(directory)
|
|
sdkCache.delete(directory)
|
|
},
|
|
})
|
|
|
|
const sdkFor = (directory: string) => {
|
|
const cached = sdkCache.get(directory)
|
|
if (cached) return cached
|
|
const sdk = globalSDK.createClient({
|
|
directory,
|
|
throwOnError: true,
|
|
})
|
|
sdkCache.set(directory, sdk)
|
|
return sdk
|
|
}
|
|
|
|
createEffect(() => {
|
|
if (!projectCacheReady()) return
|
|
if (globalStore.project.length !== 0) return
|
|
const cached = projectCache.value
|
|
if (cached.length === 0) return
|
|
setGlobalStore("project", cached)
|
|
})
|
|
|
|
createEffect(() => {
|
|
if (!projectCacheReady()) return
|
|
const projects = globalStore.project
|
|
if (projects.length === 0) {
|
|
const cachedLength = untrack(() => projectCache.value.length)
|
|
if (cachedLength !== 0) return
|
|
}
|
|
setProjectCache("value", projects.map(sanitizeProject))
|
|
})
|
|
|
|
createEffect(() => {
|
|
if (globalStore.reload !== "complete") return
|
|
setGlobalStore("reload", undefined)
|
|
queue.refresh()
|
|
})
|
|
|
|
async function loadSessions(directory: string) {
|
|
const pending = sessionLoads.get(directory)
|
|
if (pending) return pending
|
|
|
|
children.pin(directory)
|
|
const [store, setStore] = children.child(directory, { bootstrap: false })
|
|
const meta = sessionMeta.get(directory)
|
|
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" }))
|
|
}
|
|
children.unpin(directory)
|
|
return
|
|
}
|
|
|
|
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
|
|
const promise = 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,
|
|
})
|
|
setStore(
|
|
"sessionTotal",
|
|
estimateRootSessionTotal({
|
|
count: nonArchived.length,
|
|
limit: x.limit,
|
|
limited: x.limited,
|
|
}),
|
|
)
|
|
setStore("session", reconcile(sessions, { key: "id" }))
|
|
sessionMeta.set(directory, { 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, {
|
|
unknown: language.t("error.chain.unknown"),
|
|
invalidConfiguration: language.t("error.server.invalidConfiguration"),
|
|
}),
|
|
})
|
|
})
|
|
|
|
sessionLoads.set(directory, promise)
|
|
promise.finally(() => {
|
|
sessionLoads.delete(directory)
|
|
children.unpin(directory)
|
|
})
|
|
return promise
|
|
}
|
|
|
|
async function bootstrapInstance(directory: string) {
|
|
if (!directory) return
|
|
const pending = booting.get(directory)
|
|
if (pending) return pending
|
|
|
|
children.pin(directory)
|
|
const promise = (async () => {
|
|
const child = children.ensureChild(directory)
|
|
const cache = children.vcsCache.get(directory)
|
|
if (!cache) return
|
|
const sdk = sdkFor(directory)
|
|
await bootstrapDirectory({
|
|
directory,
|
|
sdk,
|
|
store: child[0],
|
|
setStore: child[1],
|
|
vcsCache: cache,
|
|
loadSessions,
|
|
unknownError: language.t("error.chain.unknown"),
|
|
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
|
|
})
|
|
})()
|
|
|
|
booting.set(directory, promise)
|
|
promise.finally(() => {
|
|
booting.delete(directory)
|
|
children.unpin(directory)
|
|
})
|
|
return promise
|
|
}
|
|
|
|
const unsub = globalSDK.event.listen((e) => {
|
|
const directory = e.name
|
|
const event = e.details
|
|
|
|
if (directory === "global") {
|
|
applyGlobalEvent({
|
|
event,
|
|
project: globalStore.project,
|
|
refresh: queue.refresh,
|
|
setGlobalProject(next) {
|
|
if (typeof next === "function") {
|
|
setGlobalStore("project", produce(next))
|
|
return
|
|
}
|
|
setGlobalStore("project", next)
|
|
},
|
|
})
|
|
if (event.type === "server.connected" || event.type === "global.disposed") {
|
|
for (const directory of Object.keys(children.children)) {
|
|
queue.push(directory)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
const existing = children.children[directory]
|
|
if (!existing) return
|
|
children.mark(directory)
|
|
const [store, setStore] = existing
|
|
applyDirectoryEvent({
|
|
event,
|
|
directory,
|
|
store,
|
|
setStore,
|
|
push: queue.push,
|
|
setSessionTodo,
|
|
vcsCache: children.vcsCache.get(directory),
|
|
loadLsp: () => {
|
|
sdkFor(directory)
|
|
.lsp.status()
|
|
.then((x) => setStore("lsp", x.data ?? []))
|
|
},
|
|
})
|
|
})
|
|
|
|
onCleanup(unsub)
|
|
onCleanup(() => {
|
|
queue.dispose()
|
|
})
|
|
onCleanup(() => {
|
|
for (const directory of Object.keys(children.children)) {
|
|
children.disposeDirectory(directory)
|
|
}
|
|
})
|
|
|
|
async function bootstrap() {
|
|
await bootstrapGlobal({
|
|
globalSDK: globalSDK.client,
|
|
connectErrorTitle: language.t("dialog.server.add.error"),
|
|
connectErrorDescription: language.t("error.globalSync.connectFailed", {
|
|
url: globalSDK.url,
|
|
}),
|
|
requestFailedTitle: language.t("common.requestFailed"),
|
|
unknownError: language.t("error.chain.unknown"),
|
|
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
|
|
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
|
setGlobalStore,
|
|
})
|
|
}
|
|
|
|
onMount(() => {
|
|
void bootstrap()
|
|
})
|
|
|
|
const projectApi = {
|
|
loadSessions,
|
|
meta(directory: string, patch: ProjectMeta) {
|
|
children.projectMeta(directory, patch)
|
|
},
|
|
icon(directory: string, value: string | undefined) {
|
|
children.projectIcon(directory, value)
|
|
},
|
|
}
|
|
|
|
const updateConfig = async (config: Config) => {
|
|
setGlobalStore("reload", "pending")
|
|
return globalSDK.client.global.config
|
|
.update({ config })
|
|
.then(bootstrap)
|
|
.then(() => {
|
|
setGlobalStore("reload", "complete")
|
|
})
|
|
.catch((error) => {
|
|
setGlobalStore("reload", undefined)
|
|
throw error
|
|
})
|
|
}
|
|
|
|
return {
|
|
data: globalStore,
|
|
set: setGlobalStore,
|
|
get ready() {
|
|
return globalStore.ready
|
|
},
|
|
get error() {
|
|
return globalStore.error
|
|
},
|
|
child: children.child,
|
|
bootstrap,
|
|
updateConfig,
|
|
project: projectApi,
|
|
todo: {
|
|
set: setSessionTodo,
|
|
},
|
|
}
|
|
}
|
|
|
|
const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
|
|
|
|
export function GlobalSyncProvider(props: ParentProps) {
|
|
const value = createGlobalSync()
|
|
return (
|
|
<Switch>
|
|
<Match when={value.ready}>
|
|
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
|
</Match>
|
|
</Switch>
|
|
)
|
|
}
|
|
|
|
export function useGlobalSync() {
|
|
const context = useContext(GlobalSyncContext)
|
|
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
|
|
return context
|
|
}
|
|
|
|
export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
|
export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|