global-sync.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943
  1. import {
  2. type Message,
  3. type Agent,
  4. type Session,
  5. type Part,
  6. type Config,
  7. type Path,
  8. type Project,
  9. type FileDiff,
  10. type Todo,
  11. type SessionStatus,
  12. type ProviderListResponse,
  13. type ProviderAuthResponse,
  14. type Command,
  15. type McpStatus,
  16. type LspStatus,
  17. type VcsInfo,
  18. type PermissionRequest,
  19. type QuestionRequest,
  20. createOpencodeClient,
  21. } from "@opencode-ai/sdk/v2/client"
  22. import { createStore, produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
  23. import { Binary } from "@opencode-ai/util/binary"
  24. import { retry } from "@opencode-ai/util/retry"
  25. import { useGlobalSDK } from "./global-sdk"
  26. import { ErrorPage, type InitError } from "../pages/error"
  27. import {
  28. batch,
  29. createContext,
  30. createEffect,
  31. untrack,
  32. getOwner,
  33. runWithOwner,
  34. useContext,
  35. onCleanup,
  36. onMount,
  37. type Accessor,
  38. type ParentProps,
  39. Switch,
  40. Match,
  41. } from "solid-js"
  42. import { showToast } from "@opencode-ai/ui/toast"
  43. import { getFilename } from "@opencode-ai/util/path"
  44. import { usePlatform } from "./platform"
  45. import { useLanguage } from "@/context/language"
  46. import { Persist, persisted } from "@/utils/persist"
  47. type ProjectMeta = {
  48. name?: string
  49. icon?: {
  50. override?: string
  51. color?: string
  52. }
  53. commands?: {
  54. start?: string
  55. }
  56. }
  57. type State = {
  58. status: "loading" | "partial" | "complete"
  59. agent: Agent[]
  60. command: Command[]
  61. project: string
  62. projectMeta: ProjectMeta | undefined
  63. icon: string | undefined
  64. provider: ProviderListResponse
  65. config: Config
  66. path: Path
  67. session: Session[]
  68. sessionTotal: number
  69. session_status: {
  70. [sessionID: string]: SessionStatus
  71. }
  72. session_diff: {
  73. [sessionID: string]: FileDiff[]
  74. }
  75. todo: {
  76. [sessionID: string]: Todo[]
  77. }
  78. permission: {
  79. [sessionID: string]: PermissionRequest[]
  80. }
  81. question: {
  82. [sessionID: string]: QuestionRequest[]
  83. }
  84. mcp: {
  85. [name: string]: McpStatus
  86. }
  87. lsp: LspStatus[]
  88. vcs: VcsInfo | undefined
  89. limit: number
  90. message: {
  91. [sessionID: string]: Message[]
  92. }
  93. part: {
  94. [messageID: string]: Part[]
  95. }
  96. }
  97. type VcsCache = {
  98. store: Store<{ value: VcsInfo | undefined }>
  99. setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
  100. ready: Accessor<boolean>
  101. }
  102. type MetaCache = {
  103. store: Store<{ value: ProjectMeta | undefined }>
  104. setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
  105. ready: Accessor<boolean>
  106. }
  107. type IconCache = {
  108. store: Store<{ value: string | undefined }>
  109. setStore: SetStoreFunction<{ value: string | undefined }>
  110. ready: Accessor<boolean>
  111. }
  112. type ChildOptions = {
  113. bootstrap?: boolean
  114. }
  115. function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
  116. return {
  117. ...input,
  118. all: input.all.map((provider) => ({
  119. ...provider,
  120. models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
  121. })),
  122. }
  123. }
  124. function createGlobalSync() {
  125. const globalSDK = useGlobalSDK()
  126. const platform = usePlatform()
  127. const language = useLanguage()
  128. const owner = getOwner()
  129. if (!owner) throw new Error("GlobalSync must be created within owner")
  130. const vcsCache = new Map<string, VcsCache>()
  131. const metaCache = new Map<string, MetaCache>()
  132. const iconCache = new Map<string, IconCache>()
  133. const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
  134. const sdkFor = (directory: string) => {
  135. const cached = sdkCache.get(directory)
  136. if (cached) return cached
  137. const sdk = createOpencodeClient({
  138. baseUrl: globalSDK.url,
  139. fetch: platform.fetch,
  140. directory,
  141. throwOnError: true,
  142. })
  143. sdkCache.set(directory, sdk)
  144. return sdk
  145. }
  146. const [projectCache, setProjectCache, , projectCacheReady] = persisted(
  147. Persist.global("globalSync.project", ["globalSync.project.v1"]),
  148. createStore({ value: [] as Project[] }),
  149. )
  150. const sanitizeProject = (project: Project) => {
  151. if (!project.icon?.url && !project.icon?.override) return project
  152. return {
  153. ...project,
  154. icon: {
  155. ...project.icon,
  156. url: undefined,
  157. override: undefined,
  158. },
  159. }
  160. }
  161. const [globalStore, setGlobalStore] = createStore<{
  162. ready: boolean
  163. error?: InitError
  164. path: Path
  165. project: Project[]
  166. provider: ProviderListResponse
  167. provider_auth: ProviderAuthResponse
  168. config: Config
  169. reload: undefined | "pending" | "complete"
  170. }>({
  171. ready: false,
  172. path: { state: "", config: "", worktree: "", directory: "", home: "" },
  173. project: projectCache.value,
  174. provider: { all: [], connected: [], default: {} },
  175. provider_auth: {},
  176. config: {},
  177. reload: undefined,
  178. })
  179. let bootstrapQueue: string[] = []
  180. createEffect(() => {
  181. if (!projectCacheReady()) return
  182. if (globalStore.project.length !== 0) return
  183. const cached = projectCache.value
  184. if (cached.length === 0) return
  185. setGlobalStore("project", cached)
  186. })
  187. createEffect(() => {
  188. if (!projectCacheReady()) return
  189. const projects = globalStore.project
  190. if (projects.length === 0) {
  191. const cachedLength = untrack(() => projectCache.value.length)
  192. if (cachedLength !== 0) return
  193. }
  194. setProjectCache("value", projects.map(sanitizeProject))
  195. })
  196. createEffect(() => {
  197. if (globalStore.reload !== "complete") return
  198. if (bootstrapQueue.length) {
  199. for (const directory of bootstrapQueue) {
  200. bootstrapInstance(directory)
  201. }
  202. bootstrap()
  203. }
  204. bootstrapQueue = []
  205. setGlobalStore("reload", undefined)
  206. })
  207. const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
  208. const booting = new Map<string, Promise<void>>()
  209. const sessionLoads = new Map<string, Promise<void>>()
  210. const sessionMeta = new Map<string, { limit: number }>()
  211. const sessionRecentWindow = 4 * 60 * 60 * 1000
  212. const sessionRecentLimit = 50
  213. function sessionUpdatedAt(session: Session) {
  214. return session.time.updated ?? session.time.created
  215. }
  216. function compareSessionRecent(a: Session, b: Session) {
  217. const aUpdated = sessionUpdatedAt(a)
  218. const bUpdated = sessionUpdatedAt(b)
  219. if (aUpdated !== bUpdated) return bUpdated - aUpdated
  220. return a.id.localeCompare(b.id)
  221. }
  222. function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
  223. if (limit <= 0) return [] as Session[]
  224. const selected: Session[] = []
  225. const seen = new Set<string>()
  226. for (const session of sessions) {
  227. if (!session?.id) continue
  228. if (seen.has(session.id)) continue
  229. seen.add(session.id)
  230. if (sessionUpdatedAt(session) <= cutoff) continue
  231. const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0)
  232. if (index === -1) selected.push(session)
  233. if (index !== -1) selected.splice(index, 0, session)
  234. if (selected.length > limit) selected.pop()
  235. }
  236. return selected
  237. }
  238. function trimSessions(input: Session[], options: { limit: number; permission: Record<string, PermissionRequest[]> }) {
  239. const limit = Math.max(0, options.limit)
  240. const cutoff = Date.now() - sessionRecentWindow
  241. const all = input
  242. .filter((s) => !!s?.id)
  243. .filter((s) => !s.time?.archived)
  244. .sort((a, b) => a.id.localeCompare(b.id))
  245. const roots = all.filter((s) => !s.parentID)
  246. const children = all.filter((s) => !!s.parentID)
  247. const base = roots.slice(0, limit)
  248. const recent = takeRecentSessions(roots.slice(limit), sessionRecentLimit, cutoff)
  249. const keepRoots = [...base, ...recent]
  250. const keepRootIds = new Set(keepRoots.map((s) => s.id))
  251. const keepChildren = children.filter((s) => {
  252. if (s.parentID && keepRootIds.has(s.parentID)) return true
  253. const perms = options.permission[s.id] ?? []
  254. if (perms.length > 0) return true
  255. return sessionUpdatedAt(s) > cutoff
  256. })
  257. return [...keepRoots, ...keepChildren].sort((a, b) => a.id.localeCompare(b.id))
  258. }
  259. function ensureChild(directory: string) {
  260. if (!directory) console.error("No directory provided")
  261. if (!children[directory]) {
  262. const vcs = runWithOwner(owner, () =>
  263. persisted(
  264. Persist.workspace(directory, "vcs", ["vcs.v1"]),
  265. createStore({ value: undefined as VcsInfo | undefined }),
  266. ),
  267. )
  268. if (!vcs) throw new Error("Failed to create persisted cache")
  269. const vcsStore = vcs[0]
  270. const vcsReady = vcs[3]
  271. vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
  272. const meta = runWithOwner(owner, () =>
  273. persisted(
  274. Persist.workspace(directory, "project", ["project.v1"]),
  275. createStore({ value: undefined as ProjectMeta | undefined }),
  276. ),
  277. )
  278. if (!meta) throw new Error("Failed to create persisted project metadata")
  279. metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
  280. const icon = runWithOwner(owner, () =>
  281. persisted(
  282. Persist.workspace(directory, "icon", ["icon.v1"]),
  283. createStore({ value: undefined as string | undefined }),
  284. ),
  285. )
  286. if (!icon) throw new Error("Failed to create persisted project icon")
  287. iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
  288. const init = () => {
  289. const child = createStore<State>({
  290. project: "",
  291. projectMeta: meta[0].value,
  292. icon: icon[0].value,
  293. provider: { all: [], connected: [], default: {} },
  294. config: {},
  295. path: { state: "", config: "", worktree: "", directory: "", home: "" },
  296. status: "loading" as const,
  297. agent: [],
  298. command: [],
  299. session: [],
  300. sessionTotal: 0,
  301. session_status: {},
  302. session_diff: {},
  303. todo: {},
  304. permission: {},
  305. question: {},
  306. mcp: {},
  307. lsp: [],
  308. vcs: vcsStore.value,
  309. limit: 5,
  310. message: {},
  311. part: {},
  312. })
  313. children[directory] = child
  314. createEffect(() => {
  315. if (!vcsReady()) return
  316. const cached = vcsStore.value
  317. if (!cached?.branch) return
  318. child[1]("vcs", (value) => value ?? cached)
  319. })
  320. createEffect(() => {
  321. child[1]("projectMeta", meta[0].value)
  322. })
  323. createEffect(() => {
  324. child[1]("icon", icon[0].value)
  325. })
  326. }
  327. runWithOwner(owner, init)
  328. }
  329. const childStore = children[directory]
  330. if (!childStore) throw new Error("Failed to create store")
  331. return childStore
  332. }
  333. function child(directory: string, options: ChildOptions = {}) {
  334. const childStore = ensureChild(directory)
  335. const shouldBootstrap = options.bootstrap ?? true
  336. if (shouldBootstrap && childStore[0].status === "loading") {
  337. void bootstrapInstance(directory)
  338. }
  339. return childStore
  340. }
  341. async function loadSessions(directory: string) {
  342. const pending = sessionLoads.get(directory)
  343. if (pending) return pending
  344. const [store, setStore] = child(directory, { bootstrap: false })
  345. const meta = sessionMeta.get(directory)
  346. if (meta && meta.limit >= store.limit) {
  347. const next = trimSessions(store.session, { limit: store.limit, permission: store.permission })
  348. if (next.length !== store.session.length) {
  349. setStore("session", reconcile(next, { key: "id" }))
  350. }
  351. return
  352. }
  353. const promise = globalSDK.client.session
  354. .list({ directory, roots: true })
  355. .then((x) => {
  356. const nonArchived = (x.data ?? [])
  357. .filter((s) => !!s?.id)
  358. .filter((s) => !s.time?.archived)
  359. .sort((a, b) => a.id.localeCompare(b.id))
  360. // Read the current limit at resolve-time so callers that bump the limit while
  361. // a request is in-flight still get the expanded result.
  362. const limit = store.limit
  363. const children = store.session.filter((s) => !!s.parentID)
  364. const sessions = trimSessions([...nonArchived, ...children], { limit, permission: store.permission })
  365. // Store total session count (used for "load more" pagination)
  366. setStore("sessionTotal", nonArchived.length)
  367. setStore("session", reconcile(sessions, { key: "id" }))
  368. sessionMeta.set(directory, { limit })
  369. })
  370. .catch((err) => {
  371. console.error("Failed to load sessions", err)
  372. const project = getFilename(directory)
  373. showToast({ title: language.t("toast.session.listFailed.title", { project }), description: err.message })
  374. })
  375. sessionLoads.set(directory, promise)
  376. promise.finally(() => {
  377. sessionLoads.delete(directory)
  378. })
  379. return promise
  380. }
  381. async function bootstrapInstance(directory: string) {
  382. if (!directory) return
  383. const pending = booting.get(directory)
  384. if (pending) return pending
  385. const promise = (async () => {
  386. const [store, setStore] = ensureChild(directory)
  387. const cache = vcsCache.get(directory)
  388. if (!cache) return
  389. const meta = metaCache.get(directory)
  390. if (!meta) return
  391. const sdk = sdkFor(directory)
  392. setStore("status", "loading")
  393. // projectMeta is synced from persisted storage in ensureChild.
  394. // vcs is seeded from persisted storage in ensureChild.
  395. const blockingRequests = {
  396. project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
  397. provider: () =>
  398. sdk.provider.list().then((x) => {
  399. setStore("provider", normalizeProviderList(x.data!))
  400. }),
  401. agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
  402. config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
  403. }
  404. try {
  405. await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
  406. } catch (err) {
  407. console.error("Failed to bootstrap instance", err)
  408. const project = getFilename(directory)
  409. const message = err instanceof Error ? err.message : String(err)
  410. showToast({ title: `Failed to reload ${project}`, description: message })
  411. setStore("status", "partial")
  412. return
  413. }
  414. if (store.status !== "complete") setStore("status", "partial")
  415. Promise.all([
  416. sdk.path.get().then((x) => setStore("path", x.data!)),
  417. sdk.command.list().then((x) => setStore("command", x.data ?? [])),
  418. sdk.session.status().then((x) => setStore("session_status", x.data!)),
  419. loadSessions(directory),
  420. sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
  421. sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
  422. sdk.vcs.get().then((x) => {
  423. const next = x.data ?? store.vcs
  424. setStore("vcs", next)
  425. if (next?.branch) cache.setStore("value", next)
  426. }),
  427. sdk.permission.list().then((x) => {
  428. const grouped: Record<string, PermissionRequest[]> = {}
  429. for (const perm of x.data ?? []) {
  430. if (!perm?.id || !perm.sessionID) continue
  431. const existing = grouped[perm.sessionID]
  432. if (existing) {
  433. existing.push(perm)
  434. continue
  435. }
  436. grouped[perm.sessionID] = [perm]
  437. }
  438. batch(() => {
  439. for (const sessionID of Object.keys(store.permission)) {
  440. if (grouped[sessionID]) continue
  441. setStore("permission", sessionID, [])
  442. }
  443. for (const [sessionID, permissions] of Object.entries(grouped)) {
  444. setStore(
  445. "permission",
  446. sessionID,
  447. reconcile(
  448. permissions.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
  449. { key: "id" },
  450. ),
  451. )
  452. }
  453. })
  454. }),
  455. sdk.question.list().then((x) => {
  456. const grouped: Record<string, QuestionRequest[]> = {}
  457. for (const question of x.data ?? []) {
  458. if (!question?.id || !question.sessionID) continue
  459. const existing = grouped[question.sessionID]
  460. if (existing) {
  461. existing.push(question)
  462. continue
  463. }
  464. grouped[question.sessionID] = [question]
  465. }
  466. batch(() => {
  467. for (const sessionID of Object.keys(store.question)) {
  468. if (grouped[sessionID]) continue
  469. setStore("question", sessionID, [])
  470. }
  471. for (const [sessionID, questions] of Object.entries(grouped)) {
  472. setStore(
  473. "question",
  474. sessionID,
  475. reconcile(
  476. questions.filter((q) => !!q?.id).sort((a, b) => a.id.localeCompare(b.id)),
  477. { key: "id" },
  478. ),
  479. )
  480. }
  481. })
  482. }),
  483. ]).then(() => {
  484. setStore("status", "complete")
  485. })
  486. })()
  487. booting.set(directory, promise)
  488. promise.finally(() => {
  489. booting.delete(directory)
  490. })
  491. return promise
  492. }
  493. const unsub = globalSDK.event.listen((e) => {
  494. const directory = e.name
  495. const event = e.details
  496. if (directory === "global") {
  497. switch (event?.type) {
  498. case "global.disposed": {
  499. if (globalStore.reload) return
  500. bootstrap()
  501. break
  502. }
  503. case "project.updated": {
  504. const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
  505. if (result.found) {
  506. setGlobalStore("project", result.index, reconcile(event.properties))
  507. return
  508. }
  509. setGlobalStore(
  510. "project",
  511. produce((draft) => {
  512. draft.splice(result.index, 0, event.properties)
  513. }),
  514. )
  515. break
  516. }
  517. }
  518. return
  519. }
  520. const existing = children[directory]
  521. if (!existing) return
  522. const [store, setStore] = existing
  523. switch (event.type) {
  524. case "server.instance.disposed": {
  525. if (globalStore.reload) {
  526. bootstrapQueue.push(directory)
  527. return
  528. }
  529. bootstrapInstance(directory)
  530. break
  531. }
  532. case "session.created": {
  533. const info = event.properties.info
  534. const result = Binary.search(store.session, info.id, (s) => s.id)
  535. if (result.found) {
  536. setStore("session", result.index, reconcile(info))
  537. break
  538. }
  539. const next = store.session.slice()
  540. next.splice(result.index, 0, info)
  541. const trimmed = trimSessions(next, { limit: store.limit, permission: store.permission })
  542. setStore("session", reconcile(trimmed, { key: "id" }))
  543. if (!info.parentID) {
  544. setStore("sessionTotal", (value) => value + 1)
  545. }
  546. break
  547. }
  548. case "session.updated": {
  549. const info = event.properties.info
  550. const result = Binary.search(store.session, info.id, (s) => s.id)
  551. if (info.time.archived) {
  552. if (result.found) {
  553. setStore(
  554. "session",
  555. produce((draft) => {
  556. draft.splice(result.index, 1)
  557. }),
  558. )
  559. }
  560. if (info.parentID) break
  561. setStore("sessionTotal", (value) => Math.max(0, value - 1))
  562. break
  563. }
  564. if (result.found) {
  565. setStore("session", result.index, reconcile(info))
  566. break
  567. }
  568. const next = store.session.slice()
  569. next.splice(result.index, 0, info)
  570. const trimmed = trimSessions(next, { limit: store.limit, permission: store.permission })
  571. setStore("session", reconcile(trimmed, { key: "id" }))
  572. break
  573. }
  574. case "session.deleted": {
  575. const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
  576. if (result.found) {
  577. setStore(
  578. "session",
  579. produce((draft) => {
  580. draft.splice(result.index, 1)
  581. }),
  582. )
  583. }
  584. if (event.properties.info.parentID) break
  585. setStore("sessionTotal", (value) => Math.max(0, value - 1))
  586. break
  587. }
  588. case "session.diff":
  589. setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" }))
  590. break
  591. case "todo.updated":
  592. setStore("todo", event.properties.sessionID, reconcile(event.properties.todos, { key: "id" }))
  593. break
  594. case "session.status": {
  595. setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
  596. break
  597. }
  598. case "message.updated": {
  599. const messages = store.message[event.properties.info.sessionID]
  600. if (!messages) {
  601. setStore("message", event.properties.info.sessionID, [event.properties.info])
  602. break
  603. }
  604. const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
  605. if (result.found) {
  606. setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
  607. break
  608. }
  609. setStore(
  610. "message",
  611. event.properties.info.sessionID,
  612. produce((draft) => {
  613. draft.splice(result.index, 0, event.properties.info)
  614. }),
  615. )
  616. break
  617. }
  618. case "message.removed": {
  619. const messages = store.message[event.properties.sessionID]
  620. if (!messages) break
  621. const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
  622. if (result.found) {
  623. setStore(
  624. "message",
  625. event.properties.sessionID,
  626. produce((draft) => {
  627. draft.splice(result.index, 1)
  628. }),
  629. )
  630. }
  631. break
  632. }
  633. case "message.part.updated": {
  634. const part = event.properties.part
  635. const parts = store.part[part.messageID]
  636. if (!parts) {
  637. setStore("part", part.messageID, [part])
  638. break
  639. }
  640. const result = Binary.search(parts, part.id, (p) => p.id)
  641. if (result.found) {
  642. setStore("part", part.messageID, result.index, reconcile(part))
  643. break
  644. }
  645. setStore(
  646. "part",
  647. part.messageID,
  648. produce((draft) => {
  649. draft.splice(result.index, 0, part)
  650. }),
  651. )
  652. break
  653. }
  654. case "message.part.removed": {
  655. const parts = store.part[event.properties.messageID]
  656. if (!parts) break
  657. const result = Binary.search(parts, event.properties.partID, (p) => p.id)
  658. if (result.found) {
  659. setStore(
  660. "part",
  661. event.properties.messageID,
  662. produce((draft) => {
  663. draft.splice(result.index, 1)
  664. }),
  665. )
  666. }
  667. break
  668. }
  669. case "vcs.branch.updated": {
  670. const next = { branch: event.properties.branch }
  671. setStore("vcs", next)
  672. const cache = vcsCache.get(directory)
  673. if (cache) cache.setStore("value", next)
  674. break
  675. }
  676. case "permission.asked": {
  677. const sessionID = event.properties.sessionID
  678. const permissions = store.permission[sessionID]
  679. if (!permissions) {
  680. setStore("permission", sessionID, [event.properties])
  681. break
  682. }
  683. const result = Binary.search(permissions, event.properties.id, (p) => p.id)
  684. if (result.found) {
  685. setStore("permission", sessionID, result.index, reconcile(event.properties))
  686. break
  687. }
  688. setStore(
  689. "permission",
  690. sessionID,
  691. produce((draft) => {
  692. draft.splice(result.index, 0, event.properties)
  693. }),
  694. )
  695. break
  696. }
  697. case "permission.replied": {
  698. const permissions = store.permission[event.properties.sessionID]
  699. if (!permissions) break
  700. const result = Binary.search(permissions, event.properties.requestID, (p) => p.id)
  701. if (!result.found) break
  702. setStore(
  703. "permission",
  704. event.properties.sessionID,
  705. produce((draft) => {
  706. draft.splice(result.index, 1)
  707. }),
  708. )
  709. break
  710. }
  711. case "question.asked": {
  712. const sessionID = event.properties.sessionID
  713. const questions = store.question[sessionID]
  714. if (!questions) {
  715. setStore("question", sessionID, [event.properties])
  716. break
  717. }
  718. const result = Binary.search(questions, event.properties.id, (q) => q.id)
  719. if (result.found) {
  720. setStore("question", sessionID, result.index, reconcile(event.properties))
  721. break
  722. }
  723. setStore(
  724. "question",
  725. sessionID,
  726. produce((draft) => {
  727. draft.splice(result.index, 0, event.properties)
  728. }),
  729. )
  730. break
  731. }
  732. case "question.replied":
  733. case "question.rejected": {
  734. const questions = store.question[event.properties.sessionID]
  735. if (!questions) break
  736. const result = Binary.search(questions, event.properties.requestID, (q) => q.id)
  737. if (!result.found) break
  738. setStore(
  739. "question",
  740. event.properties.sessionID,
  741. produce((draft) => {
  742. draft.splice(result.index, 1)
  743. }),
  744. )
  745. break
  746. }
  747. case "lsp.updated": {
  748. sdkFor(directory)
  749. .lsp.status()
  750. .then((x) => setStore("lsp", x.data ?? []))
  751. break
  752. }
  753. }
  754. })
  755. onCleanup(unsub)
  756. async function bootstrap() {
  757. const health = await globalSDK.client.global
  758. .health()
  759. .then((x) => x.data)
  760. .catch(() => undefined)
  761. if (!health?.healthy) {
  762. setGlobalStore("error", new Error(language.t("error.globalSync.connectFailed", { url: globalSDK.url })))
  763. return
  764. }
  765. return Promise.all([
  766. retry(() =>
  767. globalSDK.client.path.get().then((x) => {
  768. setGlobalStore("path", x.data!)
  769. }),
  770. ),
  771. retry(() =>
  772. globalSDK.client.config.get().then((x) => {
  773. setGlobalStore("config", x.data!)
  774. }),
  775. ),
  776. retry(() =>
  777. globalSDK.client.project.list().then(async (x) => {
  778. const projects = (x.data ?? [])
  779. .filter((p) => !!p?.id)
  780. .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
  781. .slice()
  782. .sort((a, b) => a.id.localeCompare(b.id))
  783. setGlobalStore("project", projects)
  784. }),
  785. ),
  786. retry(() =>
  787. globalSDK.client.provider.list().then((x) => {
  788. setGlobalStore("provider", normalizeProviderList(x.data!))
  789. }),
  790. ),
  791. retry(() =>
  792. globalSDK.client.provider.auth().then((x) => {
  793. setGlobalStore("provider_auth", x.data ?? {})
  794. }),
  795. ),
  796. ])
  797. .then(() => setGlobalStore("ready", true))
  798. .catch((e) => setGlobalStore("error", e))
  799. }
  800. onMount(() => {
  801. bootstrap()
  802. })
  803. function projectMeta(directory: string, patch: ProjectMeta) {
  804. const [store, setStore] = ensureChild(directory)
  805. const cached = metaCache.get(directory)
  806. if (!cached) return
  807. const previous = store.projectMeta ?? {}
  808. const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon
  809. const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands
  810. const next = {
  811. ...previous,
  812. ...patch,
  813. icon,
  814. commands,
  815. }
  816. cached.setStore("value", next)
  817. setStore("projectMeta", next)
  818. }
  819. function projectIcon(directory: string, value: string | undefined) {
  820. const [store, setStore] = ensureChild(directory)
  821. const cached = iconCache.get(directory)
  822. if (!cached) return
  823. if (store.icon === value) return
  824. cached.setStore("value", value)
  825. setStore("icon", value)
  826. }
  827. return {
  828. data: globalStore,
  829. set: setGlobalStore,
  830. get ready() {
  831. return globalStore.ready
  832. },
  833. get error() {
  834. return globalStore.error
  835. },
  836. child,
  837. bootstrap,
  838. updateConfig: async (config: Config) => {
  839. setGlobalStore("reload", "pending")
  840. const response = await globalSDK.client.config.update({ config })
  841. setTimeout(() => {
  842. setGlobalStore("reload", "complete")
  843. }, 1000)
  844. return response
  845. },
  846. project: {
  847. loadSessions,
  848. meta: projectMeta,
  849. icon: projectIcon,
  850. },
  851. }
  852. }
  853. const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
  854. export function GlobalSyncProvider(props: ParentProps) {
  855. const value = createGlobalSync()
  856. return (
  857. <Switch>
  858. <Match when={value.error}>
  859. <ErrorPage error={value.error} />
  860. </Match>
  861. <Match when={value.ready}>
  862. <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
  863. </Match>
  864. </Switch>
  865. )
  866. }
  867. export function useGlobalSync() {
  868. const context = useContext(GlobalSyncContext)
  869. if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
  870. return context
  871. }