server.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. import { createSimpleContext } from "@opencode-ai/ui/context"
  2. import { type Accessor, batch, createMemo } from "solid-js"
  3. import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
  4. import { Persist, persisted } from "@/utils/persist"
  5. import { ServerScope } from "@/utils/server-scope"
  6. type StoredProject = { worktree: string; expanded: boolean }
  7. type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
  8. type ServerProjectState = { projects: Record<string, StoredProject[]>; lastProject: Record<string, string> }
  9. const HEALTH_POLL_INTERVAL_MS = 10_000
  10. export function normalizeServerUrl(input: string) {
  11. const trimmed = input.trim()
  12. if (!trimmed) return
  13. const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
  14. return withProtocol.replace(/\/+$/, "")
  15. }
  16. export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) {
  17. if (!conn) return ""
  18. if (conn.displayName && !ignoreDisplayName) return conn.displayName
  19. return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
  20. }
  21. function isLocalHost(url: string) {
  22. const host = url.replace(/^https?:\/\//, "").split(":")[0]
  23. if (host === "localhost" || host === "127.0.0.1") return "local"
  24. }
  25. function isRecord(value: unknown): value is Record<string, unknown> {
  26. return typeof value === "object" && value !== null && !Array.isArray(value)
  27. }
  28. export function migrateCanonicalLocalServerState(value: unknown, canonicalLocalServer?: ServerConnection.Key) {
  29. if (!canonicalLocalServer || canonicalLocalServer === "local") return value
  30. if (!isRecord(value)) return value
  31. const projects = isRecord(value.projects) ? value.projects : undefined
  32. const lastProject = isRecord(value.lastProject) ? value.lastProject : undefined
  33. const previousProjects = projects?.[canonicalLocalServer]
  34. const previousLastProject = lastProject?.[canonicalLocalServer]
  35. if (!Array.isArray(previousProjects) && typeof previousLastProject !== "string") return value
  36. const next = { ...value }
  37. if (projects && Array.isArray(previousProjects)) {
  38. const local = Array.isArray(projects.local) ? projects.local : []
  39. const worktrees = new Set(
  40. local.flatMap((project) => (isRecord(project) && typeof project.worktree === "string" ? [project.worktree] : [])),
  41. )
  42. const migrated = previousProjects.filter((project) => {
  43. if (!isRecord(project) || typeof project.worktree !== "string") return true
  44. if (worktrees.has(project.worktree)) return false
  45. worktrees.add(project.worktree)
  46. return true
  47. })
  48. const nextProjects: Record<string, unknown> = { ...projects, local: [...local, ...migrated] }
  49. delete nextProjects[canonicalLocalServer]
  50. next.projects = nextProjects
  51. }
  52. if (lastProject && typeof previousLastProject === "string") {
  53. const nextLastProject = { ...lastProject }
  54. if (typeof nextLastProject.local !== "string") nextLastProject.local = previousLastProject
  55. delete nextLastProject[canonicalLocalServer]
  56. next.lastProject = nextLastProject
  57. }
  58. return next
  59. }
  60. export function createServerProjects<T extends ServerProjectState>(input: {
  61. scope: Accessor<ServerScope>
  62. store: Store<T>
  63. setStore: SetStoreFunction<T>
  64. }) {
  65. const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
  66. const current = () => input.store.projects[input.scope()] ?? []
  67. return {
  68. list: current,
  69. open(directory: string) {
  70. const scope = input.scope()
  71. if (current().some((project) => project.worktree === directory)) return
  72. setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
  73. },
  74. close(directory: string) {
  75. setStore(
  76. "projects",
  77. input.scope(),
  78. current().filter((project) => project.worktree !== directory),
  79. )
  80. },
  81. expand(directory: string) {
  82. const index = current().findIndex((project) => project.worktree === directory)
  83. if (index !== -1) setStore("projects", input.scope(), index, "expanded", true)
  84. },
  85. collapse(directory: string) {
  86. const index = current().findIndex((project) => project.worktree === directory)
  87. if (index !== -1) setStore("projects", input.scope(), index, "expanded", false)
  88. },
  89. move(directory: string, toIndex: number) {
  90. const fromIndex = current().findIndex((project) => project.worktree === directory)
  91. if (fromIndex === -1 || fromIndex === toIndex) return
  92. const next = [...current()]
  93. const [item] = next.splice(fromIndex, 1)
  94. next.splice(toIndex, 0, item)
  95. setStore("projects", input.scope(), next)
  96. },
  97. last() {
  98. return input.store.lastProject[input.scope()]
  99. },
  100. touch(directory: string) {
  101. setStore("lastProject", input.scope(), directory)
  102. },
  103. }
  104. }
  105. export function resolveServerList(input: {
  106. props?: Array<ServerConnection.Any>
  107. stored: StoredServer[]
  108. }): Array<ServerConnection.Any> {
  109. const deduped = new Map<ServerConnection.Key, ServerConnection.Any>(
  110. input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
  111. )
  112. for (const value of input.stored) {
  113. const conn: ServerConnection.Http =
  114. typeof value === "string"
  115. ? {
  116. type: "http" as const,
  117. http: { url: value },
  118. }
  119. : "http" in value
  120. ? value
  121. : { type: "http", http: value }
  122. const key = ServerConnection.key(conn)
  123. const existing = deduped.get(key)
  124. if (existing)
  125. deduped.set(key, {
  126. ...existing,
  127. ...conn,
  128. http: { ...existing.http, ...conn.http },
  129. })
  130. else deduped.set(key, conn)
  131. }
  132. return [...deduped.values()]
  133. }
  134. export namespace ServerConnection {
  135. type Base = { displayName?: string }
  136. export type HttpBase = {
  137. url: string
  138. username?: string
  139. password?: string
  140. }
  141. // Regular web connections
  142. export type Http = {
  143. type: "http"
  144. http: HttpBase
  145. authToken?: boolean
  146. } & Base
  147. export type Sidecar = {
  148. type: "sidecar"
  149. http: HttpBase
  150. } & (
  151. | // Regular desktop server
  152. { variant: "base" }
  153. // WSL server (windows only)
  154. | {
  155. variant: "wsl"
  156. distro: string
  157. }
  158. ) &
  159. Base
  160. // Remote server desktop can SSH into
  161. export type Ssh = {
  162. type: "ssh"
  163. host: string
  164. // SSH client exposes an HTTP server for the app to use as a proxy
  165. http: HttpBase
  166. } & Base
  167. export type Any =
  168. | Http
  169. // All these are desktop-only
  170. | (Sidecar | Ssh)
  171. export const key = (conn: Any): Key => {
  172. switch (conn.type) {
  173. case "http":
  174. return Key.make(conn.http.url)
  175. case "sidecar": {
  176. if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
  177. return Key.make("sidecar")
  178. }
  179. case "ssh":
  180. return Key.make(`ssh:${conn.host}`)
  181. }
  182. }
  183. export type Key = string & { _brand: "Key" }
  184. export const Key = { make: (v: string) => v as Key }
  185. }
  186. export const { use: useServer, provider: ServerProvider } = createSimpleContext({
  187. name: "Server",
  188. gate: true,
  189. init: (props: {
  190. defaultServer: ServerConnection.Key
  191. canonicalLocalServer?: ServerConnection.Key
  192. servers?: Array<ServerConnection.Any>
  193. }) => {
  194. const [store, setStore, _, ready] = persisted(
  195. {
  196. ...Persist.global("server", ["server.v3"]),
  197. migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
  198. },
  199. createStore({
  200. list: [] as StoredServer[],
  201. projects: {} as Record<string, StoredProject[]>,
  202. lastProject: {} as Record<string, string>,
  203. }),
  204. )
  205. const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
  206. const allServers = createMemo((): Array<ServerConnection.Any> => {
  207. return resolveServerList({ stored: store.list, props: props.servers })
  208. })
  209. const [state, setState] = createStore({
  210. active: props.defaultServer,
  211. })
  212. function setActive(input: ServerConnection.Key) {
  213. if (state.active !== input) setState("active", input)
  214. }
  215. function add(input: ServerConnection.Http) {
  216. const url_ = normalizeServerUrl(input.http.url)
  217. if (!url_) return
  218. const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
  219. return batch(() => {
  220. const existing = store.list.findIndex((x) => url(x) === url_)
  221. if (existing !== -1) {
  222. setStore("list", existing, conn)
  223. } else {
  224. setStore("list", store.list.length, conn)
  225. }
  226. setState("active", ServerConnection.key(conn))
  227. return conn
  228. })
  229. }
  230. function remove(key: ServerConnection.Key) {
  231. const list = store.list.filter((x) => url(x) !== key)
  232. batch(() => {
  233. setStore("list", list)
  234. if (state.active === key) {
  235. const next = list[0]
  236. setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
  237. }
  238. })
  239. }
  240. const isReady = createMemo(() => ready() && !!state.active)
  241. const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
  242. const projects = createServerProjects({ scope, store, setStore })
  243. const projectStores = new Map<ServerConnection.Key, ReturnType<typeof createServerProjects>>()
  244. const projectsForServer = (key: ServerConnection.Key) => {
  245. const existing = projectStores.get(key)
  246. if (existing) return existing
  247. const next = createServerProjects({ scope: () => scope(key), store, setStore })
  248. projectStores.set(key, next)
  249. return next
  250. }
  251. const current: Accessor<ServerConnection.Any | undefined> = createMemo(
  252. () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
  253. )
  254. const isLocal = createMemo(() => {
  255. const c = current()
  256. return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
  257. })
  258. return {
  259. ready: isReady,
  260. isLocal,
  261. get key() {
  262. return state.active
  263. },
  264. get name() {
  265. return serverName(current())
  266. },
  267. get list() {
  268. return allServers()
  269. },
  270. get current() {
  271. return current()
  272. },
  273. setActive,
  274. add,
  275. remove,
  276. scope,
  277. projects: {
  278. ...projects,
  279. forServer: projectsForServer,
  280. },
  281. }
  282. },
  283. })