import { createSimpleContext } from "@opencode-ai/ui/context" import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { usePlatform } from "@/context/platform" import { Persist, persisted } from "@/utils/persist" import { checkServerHealth } from "@/utils/server-health" type StoredProject = { worktree: string; expanded: boolean } const HEALTH_POLL_INTERVAL_MS = 10_000 export function normalizeServerUrl(input: string) { const trimmed = input.trim() if (!trimmed) return const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}` return withProtocol.replace(/\/+$/, "") } export function serverDisplayName(conn?: ServerConnection.Any) { if (!conn) return "" if (conn.displayName) return conn.displayName return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "") } function projectsKey(url: string) { if (!url) return "" const host = url.replace(/^https?:\/\//, "").split(":")[0] if (host === "localhost" || host === "127.0.0.1") return "local" return url } export namespace ServerConnection { type Base = { displayName?: string } export type HttpBase = { url: string username?: string password?: string } // Regular web connections export type Http = { type: "http" http: HttpBase } & Base export type Sidecar = { type: "sidecar" http: HttpBase } & ( | // Regular desktop server { variant: "base" } // WSL server (windows only) | { variant: "wsl" distro: string } ) & Base // Remote server desktop can SSH into export type Ssh = { type: "ssh" host: string // SSH client exposes an HTTP server for the app to use as a proxy http: HttpBase } & Base export type Any = | Http // All these are desktop-only | (Sidecar | Ssh) export const key = (conn: Any): Key => { switch (conn.type) { case "http": return Key.make(conn.http.url) case "sidecar": { if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`) return Key.make("sidecar") } case "ssh": return Key.make(`ssh:${conn.host}`) } } export type Key = string & { _brand: "Key" } export const Key = { make: (v: string) => v as Key } } export const { use: useServer, provider: ServerProvider } = createSimpleContext({ name: "Server", init: (props: { defaultServer: ServerConnection.Key; servers?: Array }) => { const platform = usePlatform() const [store, setStore, _, ready] = persisted( Persist.global("server", ["server.v3"]), createStore({ list: [] as string[], projects: {} as Record, lastProject: {} as Record, }), ) const allServers = createMemo( (): Array => [ ...(props.servers ?? []), ...store.list.map((value) => ({ type: "http" as const, http: typeof value === "string" ? { url: value } : value, })), ], ) const [state, setState] = createStore({ active: props.defaultServer, healthy: undefined as boolean | undefined, }) const healthy = () => state.healthy function startHealthPolling(conn: ServerConnection.Any) { let alive = true let busy = false const run = () => { if (busy) return busy = true void check(conn) .then((next) => { if (!alive) return setState("healthy", next) }) .finally(() => { busy = false }) } run() const interval = setInterval(run, HEALTH_POLL_INTERVAL_MS) return () => { alive = false clearInterval(interval) } } function setActive(input: ServerConnection.Key) { if (state.active !== input) setState("active", input) } function add(input: string) { const url = normalizeServerUrl(input) if (!url) return return batch(() => { const http: ServerConnection.HttpBase = { url } if (!store.list.includes(url)) { setStore("list", store.list.length, url) } const conn: ServerConnection.Http = { type: "http", http } setState("active", ServerConnection.key(conn)) return conn }) } function remove(key: ServerConnection.Key) { const list = store.list.filter((x) => x !== key) batch(() => { setStore("list", list) if (state.active === key) { const next = list[0] setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer) } }) } const isReady = createMemo(() => ready() && !!state.active) const fetcher = platform.fetch ?? globalThis.fetch const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy) createEffect(() => { const current_ = current() if (!current_) return setState("healthy", undefined) onCleanup(startHealthPolling(current_)) }) const origin = createMemo(() => projectsKey(state.active)) const projectsList = createMemo(() => store.projects[origin()] ?? []) const isLocal = createMemo(() => origin() === "local") const current: Accessor = createMemo( () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0], ) return { ready: isReady, healthy, isLocal, get key() { return state.active }, get name() { return serverDisplayName(current()) }, get list() { return allServers() }, get current() { return current() }, setActive, add, remove, projects: { list: projectsList, open(directory: string) { const key = origin() if (!key) return const current = store.projects[key] ?? [] if (current.find((x) => x.worktree === directory)) return setStore("projects", key, [{ worktree: directory, expanded: true }, ...current]) }, close(directory: string) { const key = origin() if (!key) return const current = store.projects[key] ?? [] setStore( "projects", key, current.filter((x) => x.worktree !== directory), ) }, expand(directory: string) { const key = origin() if (!key) return const current = store.projects[key] ?? [] const index = current.findIndex((x) => x.worktree === directory) if (index !== -1) setStore("projects", key, index, "expanded", true) }, collapse(directory: string) { const key = origin() if (!key) return const current = store.projects[key] ?? [] const index = current.findIndex((x) => x.worktree === directory) if (index !== -1) setStore("projects", key, index, "expanded", false) }, move(directory: string, toIndex: number) { const key = origin() if (!key) return const current = store.projects[key] ?? [] const fromIndex = current.findIndex((x) => x.worktree === directory) if (fromIndex === -1 || fromIndex === toIndex) return const result = [...current] const [item] = result.splice(fromIndex, 1) result.splice(toIndex, 0, item) setStore("projects", key, result) }, last() { const key = origin() if (!key) return return store.lastProject[key] }, touch(directory: string) { const key = origin() if (!key) return setStore("lastProject", key, directory) }, }, } }, })