272 lines
7.8 KiB
TypeScript
272 lines
7.8 KiB
TypeScript
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<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 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 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)
|
|
},
|
|
},
|
|
}
|
|
},
|
|
})
|