server.tsx 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. import { createSimpleContext } from "@opencode-ai/ui/context"
  2. import { batch, createEffect, createMemo, onCleanup } from "solid-js"
  3. import { createStore } from "solid-js/store"
  4. import { usePlatform } from "@/context/platform"
  5. import { Persist, persisted } from "@/utils/persist"
  6. import { checkServerHealth } from "@/utils/server-health"
  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; isSidecar?: boolean }) => {
  27. const platform = usePlatform()
  28. const [store, setStore, _, ready] = persisted(
  29. Persist.global("server", ["server.v3"]),
  30. createStore({
  31. list: [] as string[],
  32. currentSidecarUrl: "",
  33. projects: {} as Record<string, StoredProject[]>,
  34. lastProject: {} as Record<string, string>,
  35. }),
  36. )
  37. const [state, setState] = createStore({
  38. active: "",
  39. healthy: undefined as boolean | undefined,
  40. })
  41. const healthy = () => state.healthy
  42. function setActive(input: string) {
  43. const url = normalizeServerUrl(input)
  44. if (!url) return
  45. setState("active", url)
  46. }
  47. function add(input: string) {
  48. const url = normalizeServerUrl(input)
  49. if (!url) return
  50. const fallback = normalizeServerUrl(props.defaultUrl)
  51. if (fallback && url === fallback) {
  52. batch(() => {
  53. if (!store.list.includes(url)) {
  54. // Add the fallback url to the list if it's not already in the list
  55. setStore("list", store.list.length, url)
  56. }
  57. setState("active", url)
  58. })
  59. return
  60. }
  61. batch(() => {
  62. if (!store.list.includes(url)) {
  63. setStore("list", store.list.length, url)
  64. }
  65. setState("active", url)
  66. })
  67. }
  68. function remove(input: string) {
  69. const url = normalizeServerUrl(input)
  70. if (!url) return
  71. const list = store.list.filter((x) => x !== url)
  72. const next = state.active === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : state.active
  73. batch(() => {
  74. setStore("list", list)
  75. setState("active", next)
  76. })
  77. }
  78. createEffect(() => {
  79. if (!ready()) return
  80. if (state.active) return
  81. const url = normalizeServerUrl(props.defaultUrl)
  82. if (!url) return
  83. batch(() => {
  84. // Remove the previous startup sidecar url
  85. if(store.currentSidecarUrl) {
  86. remove(store.currentSidecarUrl)
  87. }
  88. // Add the new sidecar url
  89. if (props.isSidecar && props.defaultUrl) {
  90. add(props.defaultUrl)
  91. }
  92. setState("active", url)
  93. })
  94. console.log(store.list)
  95. })
  96. const isReady = createMemo(() => ready() && !!state.active)
  97. const fetcher = platform.fetch ?? globalThis.fetch
  98. const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
  99. createEffect(() => {
  100. const url = state.active
  101. if (!url) return
  102. setState("healthy", undefined)
  103. let alive = true
  104. let busy = false
  105. const run = () => {
  106. if (busy) return
  107. busy = true
  108. void check(url)
  109. .then((next) => {
  110. if (!alive) return
  111. setState("healthy", next)
  112. })
  113. .finally(() => {
  114. busy = false
  115. })
  116. }
  117. run()
  118. const interval = setInterval(run, 10_000)
  119. onCleanup(() => {
  120. alive = false
  121. clearInterval(interval)
  122. })
  123. })
  124. const origin = createMemo(() => projectsKey(state.active))
  125. const projectsList = createMemo(() => store.projects[origin()] ?? [])
  126. const isLocal = createMemo(() => origin() === "local")
  127. return {
  128. ready: isReady,
  129. healthy,
  130. isLocal,
  131. get url() {
  132. return state.active
  133. },
  134. get name() {
  135. return serverDisplayName(state.active)
  136. },
  137. get list() {
  138. return store.list
  139. },
  140. setActive,
  141. add,
  142. remove,
  143. projects: {
  144. list: projectsList,
  145. open(directory: string) {
  146. const key = origin()
  147. if (!key) return
  148. const current = store.projects[key] ?? []
  149. if (current.find((x) => x.worktree === directory)) return
  150. setStore("projects", key, [{ worktree: directory, expanded: true }, ...current])
  151. },
  152. close(directory: string) {
  153. const key = origin()
  154. if (!key) return
  155. const current = store.projects[key] ?? []
  156. setStore(
  157. "projects",
  158. key,
  159. current.filter((x) => x.worktree !== directory),
  160. )
  161. },
  162. expand(directory: string) {
  163. const key = origin()
  164. if (!key) return
  165. const current = store.projects[key] ?? []
  166. const index = current.findIndex((x) => x.worktree === directory)
  167. if (index !== -1) setStore("projects", key, index, "expanded", true)
  168. },
  169. collapse(directory: string) {
  170. const key = origin()
  171. if (!key) return
  172. const current = store.projects[key] ?? []
  173. const index = current.findIndex((x) => x.worktree === directory)
  174. if (index !== -1) setStore("projects", key, index, "expanded", false)
  175. },
  176. move(directory: string, toIndex: number) {
  177. const key = origin()
  178. if (!key) return
  179. const current = store.projects[key] ?? []
  180. const fromIndex = current.findIndex((x) => x.worktree === directory)
  181. if (fromIndex === -1 || fromIndex === toIndex) return
  182. const result = [...current]
  183. const [item] = result.splice(fromIndex, 1)
  184. result.splice(toIndex, 0, item)
  185. setStore("projects", key, result)
  186. },
  187. last() {
  188. const key = origin()
  189. if (!key) return
  190. return store.lastProject[key]
  191. },
  192. touch(directory: string) {
  193. const key = origin()
  194. if (!key) return
  195. setStore("lastProject", key, directory)
  196. },
  197. },
  198. }
  199. },
  200. })