| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275 |
- 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(key: ServerConnection.Key) {
- if (!key) return ""
- if (key === "sidecar") return "local"
- const host = key.replace(/^https?:\/\//, "").split(":")[0]
- if (host === "localhost" || host === "127.0.0.1") return "local"
- return key
- }
- 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<ServerConnection.Any> }) => {
- const platform = usePlatform()
- const [store, setStore, _, ready] = persisted(
- Persist.global("server", ["server.v3"]),
- createStore({
- list: [] as string[],
- projects: {} as Record<string, StoredProject[]>,
- lastProject: {} as Record<string, string>,
- }),
- )
- const allServers = createMemo(
- (): Array<ServerConnection.Any> => [
- ...(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 current: Accessor<ServerConnection.Any | undefined> = createMemo(
- () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
- )
- const isLocal = createMemo(() => {
- const c = current()
- return c?.type === "sidecar" && c.variant === "base"
- })
- 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)
- },
- },
- }
- },
- })
|