summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context/server.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src/context/server.tsx')
-rw-r--r--packages/app/src/context/server.tsx186
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,