server.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import { createSimpleContext } from "@opencode-ai/ui/context"
  2. import { type Accessor, 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. const HEALTH_POLL_INTERVAL_MS = 10_000
  9. export function normalizeServerUrl(input: string) {
  10. const trimmed = input.trim()
  11. if (!trimmed) return
  12. const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
  13. return withProtocol.replace(/\/+$/, "")
  14. }
  15. export function serverDisplayName(conn?: ServerConnection.Any) {
  16. if (!conn) return ""
  17. if (conn.displayName) return conn.displayName
  18. return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
  19. }
  20. function projectsKey(key: ServerConnection.Key) {
  21. if (!key) return ""
  22. if (key === "sidecar") return "local"
  23. const host = key.replace(/^https?:\/\//, "").split(":")[0]
  24. if (host === "localhost" || host === "127.0.0.1") return "local"
  25. return key
  26. }
  27. export namespace ServerConnection {
  28. type Base = { displayName?: string }
  29. export type HttpBase = {
  30. url: string
  31. username?: string
  32. password?: string
  33. }
  34. // Regular web connections
  35. export type Http = {
  36. type: "http"
  37. http: HttpBase
  38. } & Base
  39. export type Sidecar = {
  40. type: "sidecar"
  41. http: HttpBase
  42. } & (
  43. | // Regular desktop server
  44. { variant: "base" }
  45. // WSL server (windows only)
  46. | {
  47. variant: "wsl"
  48. distro: string
  49. }
  50. ) &
  51. Base
  52. // Remote server desktop can SSH into
  53. export type Ssh = {
  54. type: "ssh"
  55. host: string
  56. // SSH client exposes an HTTP server for the app to use as a proxy
  57. http: HttpBase
  58. } & Base
  59. export type Any =
  60. | Http
  61. // All these are desktop-only
  62. | (Sidecar | Ssh)
  63. export const key = (conn: Any): Key => {
  64. switch (conn.type) {
  65. case "http":
  66. return Key.make(conn.http.url)
  67. case "sidecar": {
  68. if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
  69. return Key.make("sidecar")
  70. }
  71. case "ssh":
  72. return Key.make(`ssh:${conn.host}`)
  73. }
  74. }
  75. export type Key = string & { _brand: "Key" }
  76. export const Key = { make: (v: string) => v as Key }
  77. }
  78. export const { use: useServer, provider: ServerProvider } = createSimpleContext({
  79. name: "Server",
  80. init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
  81. const platform = usePlatform()
  82. const [store, setStore, _, ready] = persisted(
  83. Persist.global("server", ["server.v3"]),
  84. createStore({
  85. list: [] as string[],
  86. projects: {} as Record<string, StoredProject[]>,
  87. lastProject: {} as Record<string, string>,
  88. }),
  89. )
  90. const allServers = createMemo(
  91. (): Array<ServerConnection.Any> => [
  92. ...(props.servers ?? []),
  93. ...store.list.map((value) => ({
  94. type: "http" as const,
  95. http: typeof value === "string" ? { url: value } : value,
  96. })),
  97. ],
  98. )
  99. const [state, setState] = createStore({
  100. active: props.defaultServer,
  101. healthy: undefined as boolean | undefined,
  102. })
  103. const healthy = () => state.healthy
  104. function startHealthPolling(conn: ServerConnection.Any) {
  105. let alive = true
  106. let busy = false
  107. const run = () => {
  108. if (busy) return
  109. busy = true
  110. void check(conn)
  111. .then((next) => {
  112. if (!alive) return
  113. setState("healthy", next)
  114. })
  115. .finally(() => {
  116. busy = false
  117. })
  118. }
  119. run()
  120. const interval = setInterval(run, HEALTH_POLL_INTERVAL_MS)
  121. return () => {
  122. alive = false
  123. clearInterval(interval)
  124. }
  125. }
  126. function setActive(input: ServerConnection.Key) {
  127. if (state.active !== input) setState("active", input)
  128. }
  129. function add(input: string) {
  130. const url = normalizeServerUrl(input)
  131. if (!url) return
  132. return batch(() => {
  133. const http: ServerConnection.HttpBase = { url }
  134. if (!store.list.includes(url)) {
  135. setStore("list", store.list.length, url)
  136. }
  137. const conn: ServerConnection.Http = { type: "http", http }
  138. setState("active", ServerConnection.key(conn))
  139. return conn
  140. })
  141. }
  142. function remove(key: ServerConnection.Key) {
  143. const list = store.list.filter((x) => x !== key)
  144. batch(() => {
  145. setStore("list", list)
  146. if (state.active === key) {
  147. const next = list[0]
  148. setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer)
  149. }
  150. })
  151. }
  152. const isReady = createMemo(() => ready() && !!state.active)
  153. const fetcher = platform.fetch ?? globalThis.fetch
  154. const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
  155. createEffect(() => {
  156. const current_ = current()
  157. if (!current_) return
  158. setState("healthy", undefined)
  159. onCleanup(startHealthPolling(current_))
  160. })
  161. const origin = createMemo(() => projectsKey(state.active))
  162. const projectsList = createMemo(() => store.projects[origin()] ?? [])
  163. const current: Accessor<ServerConnection.Any | undefined> = createMemo(
  164. () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
  165. )
  166. const isLocal = createMemo(() => {
  167. const c = current()
  168. return c?.type === "sidecar" && c.variant === "base"
  169. })
  170. return {
  171. ready: isReady,
  172. healthy,
  173. isLocal,
  174. get key() {
  175. return state.active
  176. },
  177. get name() {
  178. return serverDisplayName(current())
  179. },
  180. get list() {
  181. return allServers()
  182. },
  183. get current() {
  184. return current()
  185. },
  186. setActive,
  187. add,
  188. remove,
  189. projects: {
  190. list: projectsList,
  191. open(directory: string) {
  192. const key = origin()
  193. if (!key) return
  194. const current = store.projects[key] ?? []
  195. if (current.find((x) => x.worktree === directory)) return
  196. setStore("projects", key, [{ worktree: directory, expanded: true }, ...current])
  197. },
  198. close(directory: string) {
  199. const key = origin()
  200. if (!key) return
  201. const current = store.projects[key] ?? []
  202. setStore(
  203. "projects",
  204. key,
  205. current.filter((x) => x.worktree !== directory),
  206. )
  207. },
  208. expand(directory: string) {
  209. const key = origin()
  210. if (!key) return
  211. const current = store.projects[key] ?? []
  212. const index = current.findIndex((x) => x.worktree === directory)
  213. if (index !== -1) setStore("projects", key, index, "expanded", true)
  214. },
  215. collapse(directory: string) {
  216. const key = origin()
  217. if (!key) return
  218. const current = store.projects[key] ?? []
  219. const index = current.findIndex((x) => x.worktree === directory)
  220. if (index !== -1) setStore("projects", key, index, "expanded", false)
  221. },
  222. move(directory: string, toIndex: number) {
  223. const key = origin()
  224. if (!key) return
  225. const current = store.projects[key] ?? []
  226. const fromIndex = current.findIndex((x) => x.worktree === directory)
  227. if (fromIndex === -1 || fromIndex === toIndex) return
  228. const result = [...current]
  229. const [item] = result.splice(fromIndex, 1)
  230. result.splice(toIndex, 0, item)
  231. setStore("projects", key, result)
  232. },
  233. last() {
  234. const key = origin()
  235. if (!key) return
  236. return store.lastProject[key]
  237. },
  238. touch(directory: string) {
  239. const key = origin()
  240. if (!key) return
  241. setStore("lastProject", key, directory)
  242. },
  243. },
  244. }
  245. },
  246. })