global-sync.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. import {
  2. type Config,
  3. type Path,
  4. type Project,
  5. type ProviderAuthResponse,
  6. type ProviderListResponse,
  7. createOpencodeClient,
  8. } from "@opencode-ai/sdk/v2/client"
  9. import { createStore, produce, reconcile } from "solid-js/store"
  10. import { useGlobalSDK } from "./global-sdk"
  11. import type { InitError } from "../pages/error"
  12. import {
  13. createContext,
  14. createEffect,
  15. untrack,
  16. getOwner,
  17. useContext,
  18. onCleanup,
  19. onMount,
  20. type ParentProps,
  21. Switch,
  22. Match,
  23. } from "solid-js"
  24. import { showToast } from "@opencode-ai/ui/toast"
  25. import { getFilename } from "@opencode-ai/util/path"
  26. import { usePlatform } from "./platform"
  27. import { useLanguage } from "@/context/language"
  28. import { Persist, persisted } from "@/utils/persist"
  29. import { createRefreshQueue } from "./global-sync/queue"
  30. import { createChildStoreManager } from "./global-sync/child-store"
  31. import { trimSessions } from "./global-sync/session-trim"
  32. import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
  33. import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
  34. import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
  35. import { sanitizeProject } from "./global-sync/utils"
  36. import type { ProjectMeta } from "./global-sync/types"
  37. import { SESSION_RECENT_LIMIT } from "./global-sync/types"
  38. type GlobalStore = {
  39. ready: boolean
  40. error?: InitError
  41. path: Path
  42. project: Project[]
  43. provider: ProviderListResponse
  44. provider_auth: ProviderAuthResponse
  45. config: Config
  46. reload: undefined | "pending" | "complete"
  47. }
  48. function createGlobalSync() {
  49. const globalSDK = useGlobalSDK()
  50. const platform = usePlatform()
  51. const language = useLanguage()
  52. const owner = getOwner()
  53. if (!owner) throw new Error("GlobalSync must be created within owner")
  54. const stats = {
  55. evictions: 0,
  56. loadSessionsFallback: 0,
  57. }
  58. const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
  59. const booting = new Map<string, Promise<void>>()
  60. const sessionLoads = new Map<string, Promise<void>>()
  61. const sessionMeta = new Map<string, { limit: number }>()
  62. const [projectCache, setProjectCache, , projectCacheReady] = persisted(
  63. Persist.global("globalSync.project", ["globalSync.project.v1"]),
  64. createStore({ value: [] as Project[] }),
  65. )
  66. const [globalStore, setGlobalStore] = createStore<GlobalStore>({
  67. ready: false,
  68. path: { state: "", config: "", worktree: "", directory: "", home: "" },
  69. project: projectCache.value,
  70. provider: { all: [], connected: [], default: {} },
  71. provider_auth: {},
  72. config: {},
  73. reload: undefined,
  74. })
  75. const updateStats = (activeDirectoryStores: number) => {
  76. if (!import.meta.env.DEV) return
  77. ;(
  78. globalThis as {
  79. __OPENCODE_GLOBAL_SYNC_STATS?: {
  80. activeDirectoryStores: number
  81. evictions: number
  82. loadSessionsFullFetchFallback: number
  83. }
  84. }
  85. ).__OPENCODE_GLOBAL_SYNC_STATS = {
  86. activeDirectoryStores,
  87. evictions: stats.evictions,
  88. loadSessionsFullFetchFallback: stats.loadSessionsFallback,
  89. }
  90. }
  91. const paused = () => untrack(() => globalStore.reload) !== undefined
  92. const queue = createRefreshQueue({
  93. paused,
  94. bootstrap,
  95. bootstrapInstance,
  96. })
  97. const children = createChildStoreManager({
  98. owner,
  99. markStats: updateStats,
  100. incrementEvictions: () => {
  101. stats.evictions += 1
  102. updateStats(Object.keys(children.children).length)
  103. },
  104. isBooting: (directory) => booting.has(directory),
  105. isLoadingSessions: (directory) => sessionLoads.has(directory),
  106. onBootstrap: (directory) => {
  107. void bootstrapInstance(directory)
  108. },
  109. onDispose: (directory) => {
  110. queue.clear(directory)
  111. sessionMeta.delete(directory)
  112. sdkCache.delete(directory)
  113. },
  114. })
  115. const sdkFor = (directory: string) => {
  116. const cached = sdkCache.get(directory)
  117. if (cached) return cached
  118. const sdk = createOpencodeClient({
  119. baseUrl: globalSDK.url,
  120. fetch: platform.fetch,
  121. directory,
  122. throwOnError: true,
  123. })
  124. sdkCache.set(directory, sdk)
  125. return sdk
  126. }
  127. createEffect(() => {
  128. if (!projectCacheReady()) return
  129. if (globalStore.project.length !== 0) return
  130. const cached = projectCache.value
  131. if (cached.length === 0) return
  132. setGlobalStore("project", cached)
  133. })
  134. createEffect(() => {
  135. if (!projectCacheReady()) return
  136. const projects = globalStore.project
  137. if (projects.length === 0) {
  138. const cachedLength = untrack(() => projectCache.value.length)
  139. if (cachedLength !== 0) return
  140. }
  141. setProjectCache("value", projects.map(sanitizeProject))
  142. })
  143. createEffect(() => {
  144. if (globalStore.reload !== "complete") return
  145. setGlobalStore("reload", undefined)
  146. queue.refresh()
  147. })
  148. async function loadSessions(directory: string) {
  149. const pending = sessionLoads.get(directory)
  150. if (pending) return pending
  151. children.pin(directory)
  152. const [store, setStore] = children.child(directory, { bootstrap: false })
  153. const meta = sessionMeta.get(directory)
  154. if (meta && meta.limit >= store.limit) {
  155. const next = trimSessions(store.session, { limit: store.limit, permission: store.permission })
  156. if (next.length !== store.session.length) {
  157. setStore("session", reconcile(next, { key: "id" }))
  158. }
  159. children.unpin(directory)
  160. return
  161. }
  162. const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
  163. const promise = loadRootSessionsWithFallback({
  164. directory,
  165. limit,
  166. list: (query) => globalSDK.client.session.list(query),
  167. onFallback: () => {
  168. stats.loadSessionsFallback += 1
  169. updateStats(Object.keys(children.children).length)
  170. },
  171. })
  172. .then((x) => {
  173. const nonArchived = (x.data ?? [])
  174. .filter((s) => !!s?.id)
  175. .filter((s) => !s.time?.archived)
  176. .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
  177. const limit = store.limit
  178. const childSessions = store.session.filter((s) => !!s.parentID)
  179. const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission })
  180. setStore(
  181. "sessionTotal",
  182. estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }),
  183. )
  184. setStore("session", reconcile(sessions, { key: "id" }))
  185. sessionMeta.set(directory, { limit })
  186. })
  187. .catch((err) => {
  188. console.error("Failed to load sessions", err)
  189. const project = getFilename(directory)
  190. showToast({ title: language.t("toast.session.listFailed.title", { project }), description: err.message })
  191. })
  192. sessionLoads.set(directory, promise)
  193. promise.finally(() => {
  194. sessionLoads.delete(directory)
  195. children.unpin(directory)
  196. })
  197. return promise
  198. }
  199. async function bootstrapInstance(directory: string) {
  200. if (!directory) return
  201. const pending = booting.get(directory)
  202. if (pending) return pending
  203. children.pin(directory)
  204. const promise = (async () => {
  205. const child = children.ensureChild(directory)
  206. const cache = children.vcsCache.get(directory)
  207. if (!cache) return
  208. const sdk = sdkFor(directory)
  209. await bootstrapDirectory({
  210. directory,
  211. sdk,
  212. store: child[0],
  213. setStore: child[1],
  214. vcsCache: cache,
  215. loadSessions,
  216. })
  217. })()
  218. booting.set(directory, promise)
  219. promise.finally(() => {
  220. booting.delete(directory)
  221. children.unpin(directory)
  222. })
  223. return promise
  224. }
  225. const unsub = globalSDK.event.listen((e) => {
  226. const directory = e.name
  227. const event = e.details
  228. if (directory === "global") {
  229. applyGlobalEvent({
  230. event,
  231. project: globalStore.project,
  232. refresh: queue.refresh,
  233. setGlobalProject(next) {
  234. if (typeof next === "function") {
  235. setGlobalStore("project", produce(next))
  236. return
  237. }
  238. setGlobalStore("project", next)
  239. },
  240. })
  241. return
  242. }
  243. const existing = children.children[directory]
  244. if (!existing) return
  245. children.mark(directory)
  246. const [store, setStore] = existing
  247. applyDirectoryEvent({
  248. event,
  249. directory,
  250. store,
  251. setStore,
  252. push: queue.push,
  253. vcsCache: children.vcsCache.get(directory),
  254. loadLsp: () => {
  255. sdkFor(directory)
  256. .lsp.status()
  257. .then((x) => setStore("lsp", x.data ?? []))
  258. },
  259. })
  260. })
  261. onCleanup(unsub)
  262. onCleanup(() => {
  263. queue.dispose()
  264. })
  265. onCleanup(() => {
  266. for (const directory of Object.keys(children.children)) {
  267. children.disposeDirectory(directory)
  268. }
  269. })
  270. async function bootstrap() {
  271. await bootstrapGlobal({
  272. globalSDK: globalSDK.client,
  273. connectErrorTitle: language.t("dialog.server.add.error"),
  274. connectErrorDescription: language.t("error.globalSync.connectFailed", { url: globalSDK.url }),
  275. requestFailedTitle: language.t("common.requestFailed"),
  276. setGlobalStore,
  277. })
  278. }
  279. onMount(() => {
  280. void bootstrap()
  281. })
  282. function projectMeta(directory: string, patch: ProjectMeta) {
  283. children.projectMeta(directory, patch)
  284. }
  285. function projectIcon(directory: string, value: string | undefined) {
  286. children.projectIcon(directory, value)
  287. }
  288. return {
  289. data: globalStore,
  290. set: setGlobalStore,
  291. get ready() {
  292. return globalStore.ready
  293. },
  294. get error() {
  295. return globalStore.error
  296. },
  297. child: children.child,
  298. bootstrap,
  299. updateConfig: (config: Config) => {
  300. setGlobalStore("reload", "pending")
  301. return globalSDK.client.global.config.update({ config }).finally(() => {
  302. setTimeout(() => {
  303. setGlobalStore("reload", "complete")
  304. }, 1000)
  305. })
  306. },
  307. project: {
  308. loadSessions,
  309. meta: projectMeta,
  310. icon: projectIcon,
  311. },
  312. }
  313. }
  314. const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
  315. export function GlobalSyncProvider(props: ParentProps) {
  316. const value = createGlobalSync()
  317. return (
  318. <Switch>
  319. <Match when={value.ready}>
  320. <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
  321. </Match>
  322. </Switch>
  323. )
  324. }
  325. export function useGlobalSync() {
  326. const context = useContext(GlobalSyncContext)
  327. if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
  328. return context
  329. }
  330. export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
  331. export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"