diff options
Diffstat (limited to 'packages/app/src/context/server.tsx')
| -rw-r--r-- | packages/app/src/context/server.tsx | 186 |
1 files changed, 111 insertions, 75 deletions
diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 5d3d0cf3a..182f7507f 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -1,5 +1,5 @@ import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createEffect, createMemo, onCleanup } from "solid-js" +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" @@ -15,9 +15,10 @@ export function normalizeServerUrl(input: string) { return withProtocol.replace(/\/+$/, "") } -export function serverDisplayName(url: string) { - if (!url) return "" - return url.replace(/^https?:\/\//, "").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) { @@ -27,80 +28,104 @@ function projectsKey(url: string) { 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: { defaultUrl: string; isSidecar?: boolean }) => { + 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[], - currentSidecarUrl: "", 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: "", + active: props.defaultServer, healthy: undefined as boolean | undefined, }) const healthy = () => state.healthy - const defaultUrl = () => normalizeServerUrl(props.defaultUrl) - - function reconcileStartup() { - const fallback = defaultUrl() - if (!fallback) return - - const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl) - const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list - if (!props.isSidecar) { - batch(() => { - setStore("list", list) - if (store.currentSidecarUrl) setStore("currentSidecarUrl", "") - setState("active", fallback) - }) - return - } - - const nextList = list.includes(fallback) ? list : [...list, fallback] - batch(() => { - setStore("list", nextList) - setStore("currentSidecarUrl", fallback) - setState("active", fallback) - }) - } - - function updateServerList(url: string, remove = false) { - if (remove) { - const list = store.list.filter((x) => x !== url) - const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active - batch(() => { - setStore("list", list) - setState("active", next) - }) - return - } - - batch(() => { - if (!store.list.includes(url)) { - setStore("list", store.list.length, url) - } - setState("active", url) - }) - } - - function startHealthPolling(url: string) { + function startHealthPolling(conn: ServerConnection.Any) { let alive = true let busy = false const run = () => { if (busy) return busy = true - void check(url) + void check(conn) .then((next) => { if (!alive) return setState("healthy", next) @@ -118,59 +143,70 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( } } - function setActive(input: string) { - const url = normalizeServerUrl(input) - if (!url) return - setState("active", url) + function setActive(input: ServerConnection.Key) { + if (state.active !== input) setState("active", input) } function add(input: string) { const url = normalizeServerUrl(input) if (!url) return - updateServerList(url) + 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(input: string) { - const url = normalizeServerUrl(input) - if (!url) return - updateServerList(url, true) + 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) + } + }) } - createEffect(() => { - if (!ready()) return - if (state.active) return - reconcileStartup() - }) - const isReady = createMemo(() => ready() && !!state.active) const fetcher = platform.fetch ?? globalThis.fetch - const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy) + const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy) createEffect(() => { - const url = state.active - if (!url) return + const current_ = current() + if (!current_) return setState("healthy", undefined) - onCleanup(startHealthPolling(url)) + onCleanup(startHealthPolling(current_)) }) const origin = createMemo(() => projectsKey(state.active)) const projectsList = createMemo(() => store.projects[origin()] ?? []) const isLocal = createMemo(() => origin() === "local") + const current: Accessor<ServerConnection.Any | undefined> = createMemo( + () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0], + ) return { ready: isReady, healthy, isLocal, - get url() { + get key() { return state.active }, get name() { - return serverDisplayName(state.active) + return serverDisplayName(current()) }, get list() { - return store.list + return allServers() + }, + get current() { + return current() }, setActive, add, |
