| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313 |
- import { createSimpleContext } from "@opencode-ai/ui/context"
- import { type Accessor, batch, createMemo } from "solid-js"
- import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
- import { Persist, persisted } from "@/utils/persist"
- import { ServerScope } from "@/utils/server-scope"
- type StoredProject = { worktree: string; expanded: boolean }
- type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
- type ServerProjectState = { projects: Record<string, StoredProject[]>; lastProject: Record<string, string> }
- 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 serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) {
- if (!conn) return ""
- if (conn.displayName && !ignoreDisplayName) return conn.displayName
- return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
- }
- function isLocalHost(url: string) {
- const host = url.replace(/^https?:\/\//, "").split(":")[0]
- if (host === "localhost" || host === "127.0.0.1") return "local"
- }
- function isRecord(value: unknown): value is Record<string, unknown> {
- return typeof value === "object" && value !== null && !Array.isArray(value)
- }
- export function migrateCanonicalLocalServerState(value: unknown, canonicalLocalServer?: ServerConnection.Key) {
- if (!canonicalLocalServer || canonicalLocalServer === "local") return value
- if (!isRecord(value)) return value
- const projects = isRecord(value.projects) ? value.projects : undefined
- const lastProject = isRecord(value.lastProject) ? value.lastProject : undefined
- const previousProjects = projects?.[canonicalLocalServer]
- const previousLastProject = lastProject?.[canonicalLocalServer]
- if (!Array.isArray(previousProjects) && typeof previousLastProject !== "string") return value
- const next = { ...value }
- if (projects && Array.isArray(previousProjects)) {
- const local = Array.isArray(projects.local) ? projects.local : []
- const worktrees = new Set(
- local.flatMap((project) => (isRecord(project) && typeof project.worktree === "string" ? [project.worktree] : [])),
- )
- const migrated = previousProjects.filter((project) => {
- if (!isRecord(project) || typeof project.worktree !== "string") return true
- if (worktrees.has(project.worktree)) return false
- worktrees.add(project.worktree)
- return true
- })
- const nextProjects: Record<string, unknown> = { ...projects, local: [...local, ...migrated] }
- delete nextProjects[canonicalLocalServer]
- next.projects = nextProjects
- }
- if (lastProject && typeof previousLastProject === "string") {
- const nextLastProject = { ...lastProject }
- if (typeof nextLastProject.local !== "string") nextLastProject.local = previousLastProject
- delete nextLastProject[canonicalLocalServer]
- next.lastProject = nextLastProject
- }
- return next
- }
- export function createServerProjects<T extends ServerProjectState>(input: {
- scope: Accessor<ServerScope>
- store: Store<T>
- setStore: SetStoreFunction<T>
- }) {
- const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
- const current = () => input.store.projects[input.scope()] ?? []
- return {
- list: current,
- open(directory: string) {
- const scope = input.scope()
- if (current().some((project) => project.worktree === directory)) return
- setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
- },
- close(directory: string) {
- setStore(
- "projects",
- input.scope(),
- current().filter((project) => project.worktree !== directory),
- )
- },
- expand(directory: string) {
- const index = current().findIndex((project) => project.worktree === directory)
- if (index !== -1) setStore("projects", input.scope(), index, "expanded", true)
- },
- collapse(directory: string) {
- const index = current().findIndex((project) => project.worktree === directory)
- if (index !== -1) setStore("projects", input.scope(), index, "expanded", false)
- },
- move(directory: string, toIndex: number) {
- const fromIndex = current().findIndex((project) => project.worktree === directory)
- if (fromIndex === -1 || fromIndex === toIndex) return
- const next = [...current()]
- const [item] = next.splice(fromIndex, 1)
- next.splice(toIndex, 0, item)
- setStore("projects", input.scope(), next)
- },
- last() {
- return input.store.lastProject[input.scope()]
- },
- touch(directory: string) {
- setStore("lastProject", input.scope(), directory)
- },
- }
- }
- export function resolveServerList(input: {
- props?: Array<ServerConnection.Any>
- stored: StoredServer[]
- }): Array<ServerConnection.Any> {
- const deduped = new Map<ServerConnection.Key, ServerConnection.Any>(
- input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
- )
- for (const value of input.stored) {
- const conn: ServerConnection.Http =
- typeof value === "string"
- ? {
- type: "http" as const,
- http: { url: value },
- }
- : "http" in value
- ? value
- : { type: "http", http: value }
- const key = ServerConnection.key(conn)
- const existing = deduped.get(key)
- if (existing)
- deduped.set(key, {
- ...existing,
- ...conn,
- http: { ...existing.http, ...conn.http },
- })
- else deduped.set(key, conn)
- }
- return [...deduped.values()]
- }
- 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
- authToken?: boolean
- } & 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",
- gate: true,
- init: (props: {
- defaultServer: ServerConnection.Key
- canonicalLocalServer?: ServerConnection.Key
- servers?: Array<ServerConnection.Any>
- }) => {
- const [store, setStore, _, ready] = persisted(
- {
- ...Persist.global("server", ["server.v3"]),
- migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
- },
- createStore({
- list: [] as StoredServer[],
- projects: {} as Record<string, StoredProject[]>,
- lastProject: {} as Record<string, string>,
- }),
- )
- const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
- const allServers = createMemo((): Array<ServerConnection.Any> => {
- return resolveServerList({ stored: store.list, props: props.servers })
- })
- const [state, setState] = createStore({
- active: props.defaultServer,
- })
- function setActive(input: ServerConnection.Key) {
- if (state.active !== input) setState("active", input)
- }
- function add(input: ServerConnection.Http) {
- const url_ = normalizeServerUrl(input.http.url)
- if (!url_) return
- const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
- return batch(() => {
- const existing = store.list.findIndex((x) => url(x) === url_)
- if (existing !== -1) {
- setStore("list", existing, conn)
- } else {
- setStore("list", store.list.length, conn)
- }
- setState("active", ServerConnection.key(conn))
- return conn
- })
- }
- function remove(key: ServerConnection.Key) {
- const list = store.list.filter((x) => url(x) !== key)
- batch(() => {
- setStore("list", list)
- if (state.active === key) {
- const next = list[0]
- setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
- }
- })
- }
- const isReady = createMemo(() => ready() && !!state.active)
- const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
- const projects = createServerProjects({ scope, store, setStore })
- const projectStores = new Map<ServerConnection.Key, ReturnType<typeof createServerProjects>>()
- const projectsForServer = (key: ServerConnection.Key) => {
- const existing = projectStores.get(key)
- if (existing) return existing
- const next = createServerProjects({ scope: () => scope(key), store, setStore })
- projectStores.set(key, next)
- return next
- }
- 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") || (c?.type === "http" && isLocalHost(c.http.url))
- })
- return {
- ready: isReady,
- isLocal,
- get key() {
- return state.active
- },
- get name() {
- return serverName(current())
- },
- get list() {
- return allServers()
- },
- get current() {
- return current()
- },
- setActive,
- add,
- remove,
- scope,
- projects: {
- ...projects,
- forServer: projectsForServer,
- },
- }
- },
- })
|