server.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
  2. import { createSimpleContext } from "@opencode-ai/ui/context"
  3. import { batch, createEffect, createMemo, onCleanup } from "solid-js"
  4. import { createStore } from "solid-js/store"
  5. import { usePlatform } from "@/context/platform"
  6. import { Persist, persisted } from "@/utils/persist"
  7. type StoredProject = { worktree: string; expanded: boolean }
  8. export function normalizeServerUrl(input: string) {
  9. const trimmed = input.trim()
  10. if (!trimmed) return
  11. const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
  12. return withProtocol.replace(/\/+$/, "")
  13. }
  14. export function serverDisplayName(url: string) {
  15. if (!url) return ""
  16. return url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
  17. }
  18. function projectsKey(url: string) {
  19. if (!url) return ""
  20. const host = url.replace(/^https?:\/\//, "").split(":")[0]
  21. if (host === "localhost" || host === "127.0.0.1") return "local"
  22. return url
  23. }
  24. export const { use: useServer, provider: ServerProvider } = createSimpleContext({
  25. name: "Server",
  26. init: (props: { defaultUrl: string }) => {
  27. const platform = usePlatform()
  28. const [store, setStore, _, ready] = persisted(
  29. Persist.global("server", ["server.v3"]),
  30. createStore({
  31. list: [] as string[],
  32. projects: {} as Record<string, StoredProject[]>,
  33. lastProject: {} as Record<string, string>,
  34. }),
  35. )
  36. const [state, setState] = createStore({
  37. active: "",
  38. healthy: undefined as boolean | undefined,
  39. })
  40. const healthy = () => state.healthy
  41. function setActive(input: string) {
  42. const url = normalizeServerUrl(input)
  43. if (!url) return
  44. setState("active", url)
  45. }
  46. function add(input: string) {
  47. const url = normalizeServerUrl(input)
  48. if (!url) return
  49. const fallback = normalizeServerUrl(props.defaultUrl)
  50. if (fallback && url === fallback) {
  51. setState("active", url)
  52. return
  53. }
  54. batch(() => {
  55. if (!store.list.includes(url)) {
  56. setStore("list", store.list.length, url)
  57. }
  58. setState("active", url)
  59. })
  60. }
  61. function remove(input: string) {
  62. const url = normalizeServerUrl(input)
  63. if (!url) return
  64. const list = store.list.filter((x) => x !== url)
  65. const next = state.active === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : state.active
  66. batch(() => {
  67. setStore("list", list)
  68. setState("active", next)
  69. })
  70. }
  71. createEffect(() => {
  72. if (!ready()) return
  73. if (state.active) return
  74. const url = normalizeServerUrl(props.defaultUrl)
  75. if (!url) return
  76. setState("active", url)
  77. })
  78. const isReady = createMemo(() => ready() && !!state.active)
  79. const check = (url: string) => {
  80. const sdk = createOpencodeClient({
  81. baseUrl: url,
  82. fetch: platform.fetch,
  83. signal: AbortSignal.timeout(3000),
  84. })
  85. return sdk.global
  86. .health()
  87. .then((x) => x.data?.healthy === true)
  88. .catch(() => false)
  89. }
  90. createEffect(() => {
  91. const url = state.active
  92. if (!url) return
  93. setState("healthy", undefined)
  94. let alive = true
  95. let busy = false
  96. const run = () => {
  97. if (busy) return
  98. busy = true
  99. void check(url)
  100. .then((next) => {
  101. if (!alive) return
  102. setState("healthy", next)
  103. })
  104. .finally(() => {
  105. busy = false
  106. })
  107. }
  108. run()
  109. const interval = setInterval(run, 10_000)
  110. onCleanup(() => {
  111. alive = false
  112. clearInterval(interval)
  113. })
  114. })
  115. const origin = createMemo(() => projectsKey(state.active))
  116. const projectsList = createMemo(() => store.projects[origin()] ?? [])
  117. const isLocal = createMemo(() => origin() === "local")
  118. return {
  119. ready: isReady,
  120. healthy,
  121. isLocal,
  122. get url() {
  123. return state.active
  124. },
  125. get name() {
  126. return serverDisplayName(state.active)
  127. },
  128. get list() {
  129. return store.list
  130. },
  131. setActive,
  132. add,
  133. remove,
  134. projects: {
  135. list: projectsList,
  136. open(directory: string) {
  137. const key = origin()
  138. if (!key) return
  139. const current = store.projects[key] ?? []
  140. if (current.find((x) => x.worktree === directory)) return
  141. setStore("projects", key, [{ worktree: directory, expanded: true }, ...current])
  142. },
  143. close(directory: string) {
  144. const key = origin()
  145. if (!key) return
  146. const current = store.projects[key] ?? []
  147. setStore(
  148. "projects",
  149. key,
  150. current.filter((x) => x.worktree !== directory),
  151. )
  152. },
  153. expand(directory: string) {
  154. const key = origin()
  155. if (!key) return
  156. const current = store.projects[key] ?? []
  157. const index = current.findIndex((x) => x.worktree === directory)
  158. if (index !== -1) setStore("projects", key, index, "expanded", true)
  159. },
  160. collapse(directory: string) {
  161. const key = origin()
  162. if (!key) return
  163. const current = store.projects[key] ?? []
  164. const index = current.findIndex((x) => x.worktree === directory)
  165. if (index !== -1) setStore("projects", key, index, "expanded", false)
  166. },
  167. move(directory: string, toIndex: number) {
  168. const key = origin()
  169. if (!key) return
  170. const current = store.projects[key] ?? []
  171. const fromIndex = current.findIndex((x) => x.worktree === directory)
  172. if (fromIndex === -1 || fromIndex === toIndex) return
  173. const result = [...current]
  174. const [item] = result.splice(fromIndex, 1)
  175. result.splice(toIndex, 0, item)
  176. setStore("projects", key, result)
  177. },
  178. last() {
  179. const key = origin()
  180. if (!key) return
  181. return store.lastProject[key]
  182. },
  183. touch(directory: string) {
  184. const key = origin()
  185. if (!key) return
  186. setStore("lastProject", key, directory)
  187. },
  188. },
  189. }
  190. },
  191. })