314 lines
10 KiB
TypeScript
314 lines
10 KiB
TypeScript
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,
|
|
},
|
|
}
|
|
},
|
|
})
|