server.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  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; label?: 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. export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base"
  186. export const local = (conn?: Any) =>
  187. !!conn && (builtin(conn) || (conn.type === "http" && isLocalHost(conn.http.url) === "local"))
  188. }
  189. export function nextServerAfterRemoval(
  190. servers: ServerConnection.Any[],
  191. removed: ServerConnection.Key,
  192. fallback: ServerConnection.Key,
  193. ) {
  194. const remaining = servers.filter((server) => ServerConnection.key(server) !== removed)
  195. const next = remaining.find((server) => ServerConnection.key(server) === fallback) ?? remaining[0]
  196. return next ? ServerConnection.key(next) : fallback
  197. }
  198. export const { use: useServer, provider: ServerProvider } = createSimpleContext({
  199. name: "Server",
  200. gate: true,
  201. init: (props: {
  202. defaultServer: ServerConnection.Key
  203. canonicalLocalServer?: ServerConnection.Key
  204. servers?: Array<ServerConnection.Any>
  205. }) => {
  206. const [store, setStore, _, ready] = persisted(
  207. {
  208. ...Persist.global("server", ["server.v3"]),
  209. migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
  210. },
  211. createStore({
  212. list: [] as StoredServer[],
  213. projects: {} as Record<string, StoredProject[]>,
  214. lastProject: {} as Record<string, string>,
  215. }),
  216. )
  217. const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
  218. const allServers = createMemo((): Array<ServerConnection.Any> => {
  219. return resolveServerList({ stored: store.list, props: props.servers })
  220. })
  221. const [state, setState] = createStore({
  222. active: props.defaultServer,
  223. })
  224. function setActive(input: ServerConnection.Key) {
  225. if (state.active !== input) setState("active", input)
  226. }
  227. function add(input: ServerConnection.Http) {
  228. const url_ = normalizeServerUrl(input.http.url)
  229. if (!url_) return
  230. const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
  231. return batch(() => {
  232. const existing = store.list.findIndex((x) => url(x) === url_)
  233. if (existing !== -1) {
  234. setStore("list", existing, conn)
  235. } else {
  236. setStore("list", store.list.length, conn)
  237. }
  238. setState("active", ServerConnection.key(conn))
  239. return conn
  240. })
  241. }
  242. function remove(key: ServerConnection.Key) {
  243. const next = nextServerAfterRemoval(allServers(), key, props.defaultServer)
  244. const list = store.list.filter((x) => url(x) !== key)
  245. batch(() => {
  246. setStore("list", list)
  247. if (state.active === key) setState("active", next)
  248. })
  249. }
  250. const isReady = createMemo(() => ready() && !!state.active)
  251. const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
  252. const projects = createServerProjects({ scope, store, setStore })
  253. const projectStores = new Map<ServerConnection.Key, ReturnType<typeof createServerProjects>>()
  254. const projectsForServer = (key: ServerConnection.Key) => {
  255. const existing = projectStores.get(key)
  256. if (existing) return existing
  257. const next = createServerProjects({ scope: () => scope(key), store, setStore })
  258. projectStores.set(key, next)
  259. return next
  260. }
  261. const current: Accessor<ServerConnection.Any | undefined> = createMemo(
  262. () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
  263. )
  264. const isLocal = createMemo(() => ServerConnection.local(current()))
  265. return {
  266. ready: isReady,
  267. isLocal,
  268. get key() {
  269. return state.active
  270. },
  271. get name() {
  272. return serverName(current())
  273. },
  274. get list() {
  275. return allServers()
  276. },
  277. get current() {
  278. return current()
  279. },
  280. setActive,
  281. add,
  282. remove,
  283. scope,
  284. projects: {
  285. ...projects,
  286. forServer: projectsForServer,
  287. },
  288. }
  289. },
  290. })