feat(app): add servers tab to settings dialog (#29675)

This commit is contained in:
Brendan Allan 2026-06-03 16:25:45 +08:00 committed by GitHub
commit c6f684366a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 920 additions and 412 deletions

View file

@ -0,0 +1,248 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createEffect, createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerConnection, useServer } from "./server"
import { useServerHealth } from "@/utils/server-health"
import { QueryClient } from "@tanstack/solid-query"
import { createServerSdkContext } from "./server-sdk"
import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web"
import { Persist, persisted } from "@/utils/persist"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
const server = useServer()
const serverHealth = useServerHealth(
() => server.list,
() => true,
)
const [store, setStore] = createStore({
settings: {
serverKey: undefined as ServerConnection.Key | undefined,
},
})
const serversAndProjects = createServersAndProjectStore()
const settingsServer = createMemo(() => {
const list = server.list
return list.find((conn) => ServerConnection.key(conn) === store.settings.serverKey) ?? list[0]
})
createEffect(() => {
const conn = settingsServer()
const key = conn ? ServerConnection.key(conn) : undefined
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
})
const serverCtxs = new Map<
ServerConnection.Key,
{ dispose: () => void; serverCtx: ReturnType<typeof createServerCtx> }
>()
const owner = getOwner()
createMemo(() => {
for (const conn of server.list) {
const key = ServerConnection.key(conn)
if (!serverCtxs.has(key)) {
const root = createRoot((dispose) => {
const serverCtx = createServerCtx(conn, serversAndProjects)
return { dispose, serverCtx }
}, owner as any)
serverCtxs.set(key, root)
}
}
for (const [key] of serverCtxs) {
if (!server.list.find((conn) => ServerConnection.key(conn) === key)) {
const { dispose } = serverCtxs.get(key)!
dispose()
serverCtxs.delete(key)
}
}
})
const allServers = createMemo(
(): Array<ServerConnection.Any> =>
resolveServerList({ stored: serversAndProjects.store.list, props: props.servers }),
)
return {
servers: {
list: allServers,
health: serverHealth,
},
settings: {
server: {
get key() {
return store.settings.serverKey
},
selected: settingsServer,
set(key: ServerConnection.Key) {
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
},
},
},
createServerCtx(conn: ServerConnection.Any) {
const key = ServerConnection.key(conn)
const ctx = serverCtxs.get(key)
if (!ctx) return createServerCtx(conn, serversAndProjects)
return ctx.serverCtx
},
}
},
})
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
const createServersAndProjectStore = () => {
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
createStore({
list: [] as StoredServer[],
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
}),
)
return { store, setStore, ready }
}
function createServerCtx(
conn: ServerConnection.Any,
{ store, setStore }: ReturnType<typeof createServersAndProjectStore>,
) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnReconnect: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
})
const sdk = createServerSdkContext(conn)
const sync = createServerSyncContext(sdk)
const key = ServerConnection.key(conn)
const storeKey = projectsKey(key)
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = sync.child(project.worktree, { bootstrap: false })
const projectID = childStore.project
const metadata = projectID
? sync.data.project.find((x) => x.id === projectID)
: sync.data.project.find((x) => x.worktree === project.worktree)
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// Without this, different subdirectories of the same git repo would share the same
// icon from the database instead of using their individual overrides.
const base = { ...metadata, ...project }
if (childStore.icon) {
return { ...base, icon: { ...base.icon, override: childStore.icon } }
}
return base
}
const projectsList = createMemo(() => (store.projects[storeKey] ?? []).map(enrich))
const isLocal =
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
return {
queryClient,
sdk,
sync,
isLocal,
projects: {
list: projectsList,
open(directory: string) {
const current = store.projects[storeKey] ?? []
if (current.find((x) => x.worktree === directory)) return
setStore("projects", storeKey, [{ worktree: directory, expanded: true }, ...current])
},
close(directory: string) {
const current = store.projects[storeKey] ?? []
setStore(
"projects",
storeKey,
current.filter((x) => x.worktree !== directory),
)
},
expand(directory: string) {
const current = store.projects[storeKey] ?? []
const index = current.findIndex((x) => x.worktree === directory)
if (index !== -1) setStore("projects", storeKey, index, "expanded", true)
},
collapse(directory: string) {
const current = store.projects[storeKey] ?? []
const index = current.findIndex((x) => x.worktree === directory)
if (index !== -1) setStore("projects", storeKey, index, "expanded", false)
},
move(directory: string, toIndex: number) {
const current = store.projects[storeKey] ?? []
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", storeKey, result)
},
last() {
return store.lastProject[storeKey]
},
touch(directory: string) {
setStore("lastProject", storeKey, directory)
},
},
}
}
export type ServerCtx = ReturnType<typeof createServerCtx>
function isLocalHost(url: string) {
const host = url.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local"
}
function projectsKey(key: ServerConnection.Key) {
if (key === "sidecar") return "local"
if (isLocalHost(key)) return "local"
return key
}
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()]
}

View file

@ -8,11 +8,12 @@ import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
function createServerSdkContext(server: ServerConnection.Any) {
export function createServerSdkContext(server: ServerConnection.Any) {
const platform = usePlatform()
const abort = new AbortController()
@ -244,18 +245,22 @@ function createServerSdkContext(server: ServerConnection.Any) {
}
}
export type ServerSDK = ReturnType<typeof createServerSdkContext>
export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
name: "ServerSDK",
init: () => {
init: (props: { server?: ServerConnection.Any }) => {
const global = useGlobal()
const language = useLanguage()
const server = useServer()
if (!server.current) throw new Error(language.t("error.serverSDK.noServerAvailable"))
const sdk = createServerSdkContext(server.current)
return {
...sdk,
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
}
const conn = props.server ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
const ctx = global.createServerCtx(conn)
return Object.assign(ctx.sdk, {
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, ctx.sdk)),
})
},
})
@ -263,7 +268,7 @@ type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
}
function createDirSdkContext(directory: string, serverSDK: ReturnType<typeof createServerSdkContext>) {
function createDirSdkContext(directory: string, serverSDK: ServerSDK) {
const client = serverSDK.createClient({
directory,
throwOnError: true,

View file

@ -1,11 +1,21 @@
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
import {
batch,
createContext,
createEffect,
getOwner,
onCleanup,
onMount,
type ParentProps,
untrack,
useContext,
} from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error"
import { useServerSDK } from "./server-sdk"
import { ServerSDK, useServerSDK } from "./server-sdk"
import {
bootstrapDirectory,
bootstrapGlobal,
@ -31,6 +41,8 @@ import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { retry } from "@opencode-ai/core/util/retry"
type GlobalStore = {
@ -74,8 +86,8 @@ function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: Path
}
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
export function createServerSyncContext() {
const serverSDK = useServerSDK()
export function createServerSyncContext(_serverSDK?: ServerSDK) {
const serverSDK: ServerSDK = _serverSDK ?? useServerSDK()
const language = useLanguage()
const owner = getOwner()
if (!owner) throw new Error("ServerSync must be created within owner")
@ -105,7 +117,7 @@ export function createServerSyncContext() {
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
get ready() {
return bootstrap.isPending
return !bootstrap.isPending
},
project: [],
session_todo: {},
@ -128,6 +140,7 @@ export function createServerSyncContext() {
return updateConfigMutation.isPending ? "pending" : undefined
},
})
const queryClient = useQueryClient()
let bootedAt = 0
@ -465,17 +478,22 @@ export function createServerSyncContext() {
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
name: "ServerSync",
init: () => {
const sync = createServerSyncContext()
init: (props: { server?: ServerConnection.Any }) => {
const global = useGlobal()
const language = useLanguage()
const server = useServer()
return {
...sync,
const conn = props.server ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
const ctx = global.createServerCtx(conn)
return Object.assign(ctx.sync, {
createDirSyncContext: createRefCountMap(
(dir) => createDirSyncContext(dir, sync),
(dir) => sync.disableMcp(dir),
(dir) => createDirSyncContext(dir, ctx.sync),
(dir) => ctx.sync.disableMcp(dir),
directoryKey,
),
}
})
},
})

View file

@ -1,20 +0,0 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServer } from "./server"
import { useServerHealth } from "@/utils/server-health"
export const { use: useServers, provider: ServersProvider } = createSimpleContext({
name: "Servers",
init: () => {
const server = useServer()
const health = useServerHealth(
() => server.list,
() => true,
)
return {
list: () => server.list,
health,
}
},
})

View file

@ -159,6 +159,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
init: () => {
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
createEffect(() => {
console.log("settings", { ready: ready() })
})
createEffect(() => {
if (typeof document === "undefined") return
const root = document.documentElement