diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 23102204c6..bce2626ab1 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -6,22 +6,17 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { List } from "@opencode-ai/ui/list" import { TextField } from "@opencode-ai/ui/text-field" -import { useMutation } from "@tanstack/solid-query" -import { showToast } from "@/utils/toast" -import { useNavigate } from "@solidjs/router" -import { createEffect, createMemo, createResource, Show } from "solid-js" -import { createStore } from "solid-js/store" +import { Show } from "solid-js" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" -import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" -import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" -import { detectServerProtocol } from "@/utils/server-protocol" -import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" +import { ServerConnection } from "@/context/server" import { useSettings } from "@/context/settings" -import { useTabs } from "@/context/tabs" - -const DEFAULT_USERNAME = "opencode" +import { + type ServerDomainController, + type ServerFormController, + useServerDomainController, + useServerFormController, +} from "@/components/server/server-management-controller" interface ServerFormProps { value: string @@ -40,76 +35,6 @@ interface ServerFormProps { onBack: () => void } -function showRequestError(language: ReturnType, err: unknown) { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) -} - -function useDefaultServer() { - const language = useLanguage() - const platform = usePlatform() - const [defaultKey, defaultUrlActions] = createResource( - async () => { - try { - const key = await platform.getDefaultServer?.() - if (!key) return null - return key - } catch (err) { - showRequestError(language, err) - return null - } - }, - { initialValue: null }, - ) - - const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer) - const setDefault = async (key: ServerConnection.Key | null) => { - try { - await platform.setDefaultServer?.(key) - defaultUrlActions.mutate(key) - } catch (err) { - showRequestError(language, err) - } - } - - return { defaultKey: () => defaultKey.latest, canDefault, setDefault } -} - -function useServerPreview() { - const checkServerHealth = useCheckServerHealth() - - const looksComplete = (value: string) => { - const normalized = normalizeServerUrl(value) - if (!normalized) return false - const host = normalized.replace(/^https?:\/\//, "").split("/")[0] - if (!host) return false - if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true - return host.includes(".") || host.includes(":") - } - - const previewStatus = async ( - value: string, - username: string, - password: string, - setStatus: (value: boolean | undefined) => void, - ) => { - setStatus(undefined) - if (!looksComplete(value)) return - const normalized = normalizeServerUrl(value) - if (!normalized) return - const http: ServerConnection.HttpBase = { url: normalized } - if (username) http.username = username - if (password) http.password = password - const result = await checkServerHealth(http) - setStatus(result.healthy) - } - - return { previewStatus } -} - function ServerForm(props: ServerFormProps) { const language = useLanguage() const keyDown = (event: KeyboardEvent) => { @@ -177,401 +102,40 @@ function ServerForm(props: ServerFormProps) { export function DialogSelectServer() { const dialog = useDialog() - const controller = useServerManagementController({ onSelect: dialog.close }) + const language = useLanguage() + const domain = useServerDomainController({ onSelect: () => dialog.close() }) + const form = useServerFormController({ onSelect: () => dialog.close() }) + const title = () => { + if (!form.state.open()) return language.t("dialog.server.title") + return ( +
+ + + {form.state.adding() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")} + +
+ ) + } return ( - +
- }> - + } + > +
) } -export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) { - const navigate = useNavigate() - const server = useServer() - const tabs = useTabs() - const global = useGlobal() - const platform = usePlatform() - const language = useLanguage() - const { defaultKey, canDefault, setDefault } = useDefaultServer() - const { previewStatus } = useServerPreview() - const checkServerHealth = useCheckServerHealth() - const [store, setStore] = createStore({ - addServer: { - url: "", - name: "", - username: DEFAULT_USERNAME, - password: "", - error: "", - showForm: false, - status: undefined as boolean | undefined, - }, - editServer: { - id: undefined as string | undefined, - value: "", - name: "", - username: "", - password: "", - error: "", - status: undefined as boolean | undefined, - }, - }) - - const resetAdd = () => { - setStore("addServer", { - url: "", - name: "", - username: DEFAULT_USERNAME, - password: "", - error: "", - showForm: false, - status: undefined, - }) - } - const resetEdit = () => { - setStore("editServer", { - id: undefined, - value: "", - name: "", - username: "", - password: "", - error: "", - status: undefined, - }) - } - - const addMutation = useMutation(() => ({ - mutationFn: async (value: string) => { - const normalized = normalizeServerUrl(value) - if (!normalized) { - resetAdd() - return - } - - const conn: ServerConnection.Http = { - type: "http", - http: { url: normalized }, - } - if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim() - if (store.addServer.password) conn.http.password = store.addServer.password - if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username - const result = await checkServerHealth(conn.http) - if (!result.healthy) { - setStore("addServer", { error: language.t("dialog.server.add.error") }) - return - } - if ( - !settings.general.newLayoutDesigns() && - (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2" - ) { - setStore("addServer", { error: language.t("dialog.server.add.error") }) - return - } - - resetAdd() - if (options.navigateOnAdd === false) { - server.add(conn) - options.onSelect?.() - return - } - await select(conn, true) - }, - })) - - const editMutation = useMutation(() => ({ - mutationFn: async (input: { original: ServerConnection.Any; value: string }) => { - if (input.original.type !== "http") return - const normalized = normalizeServerUrl(input.value) - if (!normalized) { - resetEdit() - return - } - - const name = store.editServer.name.trim() || undefined - const username = store.editServer.username || undefined - const password = store.editServer.password || undefined - const existingName = input.original.displayName - if ( - normalized === input.original.http.url && - name === existingName && - username === input.original.http.username && - password === input.original.http.password - ) { - resetEdit() - return - } - - const conn: ServerConnection.Http = { - type: "http", - displayName: name, - http: { url: normalized, username, password }, - } - const result = await checkServerHealth(conn.http) - if (!result.healthy) { - setStore("editServer", { error: language.t("dialog.server.add.error") }) - return - } - if ( - !settings.general.newLayoutDesigns() && - (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2" - ) { - setStore("editServer", { error: language.t("dialog.server.add.error") }) - return - } - if (normalized === input.original.http.url) { - server.add(conn) - } else { - replaceServer(input.original, conn) - } - - resetEdit() - }, - })) - - const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { - const originalKey = ServerConnection.key(original) - const active = server.key - tabs.removeServer(originalKey) - const newConn = server.add(next) - if (!newConn) return - const nextActive = active === originalKey ? ServerConnection.key(newConn) : active - if (nextActive) server.setActive(nextActive) - server.remove(originalKey) - } - - const items = createMemo(() => { - const current = server.current - const list = server.list - if (!current) return list - if (!list.includes(current)) return [current, ...list] - return [current, ...list.filter((x) => x !== current)] - }) - - const settings = useSettings() - const current = createMemo(() => - settings.general.newLayoutDesigns() - ? undefined - : (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]), - ) - - const sortedItems = createMemo(() => { - const raw = items() - const list = settings.general.newLayoutDesigns() - ? raw - : raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2") - if (!list.length) return list - const active = current() - const order = new Map(list.map((url, index) => [url, index] as const)) - const rank = (value?: ServerHealth) => { - if (value?.healthy === true) return 0 - if (value?.healthy === false) return 2 - return 1 - } - return list.slice().sort((a, b) => { - if (a === active) return -1 - if (b === active) return 1 - const diff = - rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)]) - if (diff !== 0) return diff - return (order.get(a) ?? 0) - (order.get(b) ?? 0) - }) - }) - - async function select(conn: ServerConnection.Any, persist?: boolean) { - if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return - options.onSelect?.() - if (persist && conn.type === "http") { - server.add(conn) - navigate("/") - return - } - navigate("/") - queueMicrotask(() => server.setActive(ServerConnection.key(conn))) - } - - const handleAddChange = (value: string) => { - if (addMutation.isPending) return - setStore("addServer", { url: value, error: "" }) - void previewStatus(value, store.addServer.username, store.addServer.password, (next) => - setStore("addServer", { status: next }), - ) - } - - const handleAddNameChange = (value: string) => { - if (addMutation.isPending) return - setStore("addServer", { name: value, error: "" }) - } - - const handleAddUsernameChange = (value: string) => { - if (addMutation.isPending) return - setStore("addServer", { username: value, error: "" }) - void previewStatus(store.addServer.url, value, store.addServer.password, (next) => - setStore("addServer", { status: next }), - ) - } - - const handleAddPasswordChange = (value: string) => { - if (addMutation.isPending) return - setStore("addServer", { password: value, error: "" }) - void previewStatus(store.addServer.url, store.addServer.username, value, (next) => - setStore("addServer", { status: next }), - ) - } - - const handleEditChange = (value: string) => { - if (editMutation.isPending) return - setStore("editServer", { value, error: "" }) - void previewStatus(value, store.editServer.username, store.editServer.password, (next) => - setStore("editServer", { status: next }), - ) - } - - const handleEditNameChange = (value: string) => { - if (editMutation.isPending) return - setStore("editServer", { name: value, error: "" }) - } - - const handleEditUsernameChange = (value: string) => { - if (editMutation.isPending) return - setStore("editServer", { username: value, error: "" }) - void previewStatus(store.editServer.value, value, store.editServer.password, (next) => - setStore("editServer", { status: next }), - ) - } - - const handleEditPasswordChange = (value: string) => { - if (editMutation.isPending) return - setStore("editServer", { password: value, error: "" }) - void previewStatus(store.editServer.value, store.editServer.username, value, (next) => - setStore("editServer", { status: next }), - ) - } - - const mode = createMemo<"list" | "add" | "edit">(() => { - if (store.editServer.id) return "edit" - if (store.addServer.showForm) return "add" - return "list" - }) - - const editing = createMemo(() => { - if (!store.editServer.id) return - return items().find((x) => x.type === "http" && x.http.url === store.editServer.id) - }) - - const resetForm = () => { - resetAdd() - resetEdit() - } - - const startAdd = () => { - resetEdit() - setStore("addServer", { - showForm: true, - url: "", - name: "", - username: DEFAULT_USERNAME, - password: "", - error: "", - status: undefined, - }) - } - - const startEdit = (conn: ServerConnection.Http) => { - resetAdd() - setStore("editServer", { - id: conn.http.url, - value: conn.http.url, - name: conn.displayName ?? "", - username: conn.http.username ?? "", - password: conn.http.password ?? "", - error: "", - status: global.servers.health[ServerConnection.key(conn)]?.healthy, - }) - } - - const submitForm = () => { - if (mode() === "add") { - if (addMutation.isPending) return - setStore("addServer", { error: "" }) - addMutation.mutate(store.addServer.url) - return - } - const original = editing() - if (!original) return - if (editMutation.isPending) return - setStore("editServer", { error: "" }) - editMutation.mutate({ original, value: store.editServer.value }) - } - - const isFormMode = createMemo(() => mode() !== "list") - const isAddMode = createMemo(() => mode() === "add") - const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending)) - - const formTitle = createMemo(() => { - if (!isFormMode()) return language.t("dialog.server.title") - return ( -
- - {isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")} -
- ) - }) - - createEffect(() => { - if (!store.editServer.id) return - if (editing()) return - resetEdit() - }) - - async function handleRemove(key: ServerConnection.Key) { - try { - if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key) - tabs.removeServer(key) - server.remove(key) - if ((await platform.getDefaultServer?.()) === key) { - await setDefault(null) - } - } catch (err) { - showRequestError(language, err) - } - } - - return { - defaultKey, - canDefault, - current, - sortedItems, - status: () => global.servers.health, - isFormMode, - isAddMode, - formTitle, - formBusy, - formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value), - formName: () => (isAddMode() ? store.addServer.name : store.editServer.name), - formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username), - formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password), - formError: () => (isAddMode() ? store.addServer.error : store.editServer.error), - formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status), - select, - setDefault, - startAdd, - startEdit, - resetForm, - submitForm, - canRemove: server.canRemove, - handleRemove, - handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange), - handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange), - handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange), - handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange), - } -} - -export function ServerConnectionList(props: { controller: ReturnType }) { +export function ServerConnectionList(props: { + domain: ServerDomainController + onAdd: () => void + onEdit: (server: ServerConnection.Http) => void +}) { const language = useLanguage() const settings = useSettings() @@ -585,10 +149,10 @@ export function ServerConnectionList(props: { controller: ReturnType x.http.url} onSelect={(x) => { - if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x) + if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x) }} divider={true} > @@ -597,15 +161,15 @@ export function ServerConnectionList(props: { controller: ReturnType
- +
+ {language.t("dialog.server.status.default")} @@ -614,7 +178,12 @@ export function ServerConnectionList(props: { controller: ReturnType
- + @@ -633,27 +202,27 @@ export function ServerConnectionList(props: { controller: ReturnType { if (i.type !== "http") return - props.controller.startEdit(i) + props.onEdit(i) }} > {language.t("dialog.server.menu.edit")} - - props.controller.setDefault(key)}> + + props.domain.defaults.set(key)}> {language.t("dialog.server.menu.default")} - - props.controller.setDefault(null)}> + + props.domain.defaults.set(null)}> {language.t("dialog.server.menu.defaultRemove")} - + props.controller.handleRemove(ServerConnection.key(i))} + onSelect={() => props.domain.connection.remove(key)} class="text-text-on-critical-base hover:bg-surface-critical-weak" > {language.t("dialog.server.menu.delete")} @@ -674,7 +243,7 @@ export function ServerConnectionList(props: { controller: ReturnType {language.t("dialog.server.add.button")} @@ -684,38 +253,38 @@ export function ServerConnectionList(props: { controller: ReturnType }) { +export function ServerConnectionForm(props: { form: ServerFormController }) { const language = useLanguage() return (
diff --git a/packages/app/src/components/server/server-management-controller.ts b/packages/app/src/components/server/server-management-controller.ts new file mode 100644 index 0000000000..25a85ec107 --- /dev/null +++ b/packages/app/src/components/server/server-management-controller.ts @@ -0,0 +1,336 @@ +import { useNavigate } from "@solidjs/router" +import { useMutation } from "@tanstack/solid-query" +import { createEffect, createMemo, createResource, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" +import { useSettings } from "@/context/settings" +import { useTabs } from "@/context/tabs" +import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" +import { detectServerProtocol } from "@/utils/server-protocol" +import { showToast } from "@/utils/toast" +import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management" + +const DEFAULT_USERNAME = "opencode" + +type FormMode = "list" | "add" | "edit" + +function showRequestError(language: ReturnType, err: unknown) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + +function useDefaultServer() { + const language = useLanguage() + const platform = usePlatform() + const [defaultKey, defaultKeyActions] = createResource( + async () => { + try { + return (await platform.getDefaultServer?.()) ?? null + } catch (err) { + showRequestError(language, err) + return null + } + }, + { initialValue: null }, + ) + + const set = async (key: ServerConnection.Key | null) => { + try { + await platform.setDefaultServer?.(key) + defaultKeyActions.mutate(key) + } catch (err) { + showRequestError(language, err) + } + } + + return { + key: () => defaultKey.latest, + available: createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer), + set, + } +} + +function useServerMutations() { + const server = useServer() + const tabs = useTabs() + + return { + add: (connection: ServerConnection.Http) => server.add(connection), + replace: (original: ServerConnection.Http, next: ServerConnection.Http) => + replaceServerConnection(original, next, { + active: () => server.key, + removeTabs: (key) => tabs.removeServer(key), + add: (connection) => server.add(connection), + setActive: (key) => server.setActive(key), + remove: (key) => server.remove(key), + }), + } +} + +export function useServerActionsController() { + const server = useServer() + const tabs = useTabs() + const platform = usePlatform() + const language = useLanguage() + const defaults = useDefaultServer() + + const remove = async (key: ServerConnection.Key) => { + try { + if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key) + tabs.removeServer(key) + server.remove(key) + if ((await platform.getDefaultServer?.()) === key) await defaults.set(null) + } catch (err) { + showRequestError(language, err) + } + } + + return { defaults, connection: { canRemove: server.canRemove, remove } } +} + +export type ServerActionsController = ReturnType + +export function useServerCollectionController() { + const server = useServer() + const global = useGlobal() + const settings = useSettings() + const actions = useServerActionsController() + + const items = createMemo(() => { + const current = server.current + const list = server.list + if (!current) return list + if (!list.includes(current)) return [current, ...list] + return [current, ...list.filter((item) => item !== current)] + }) + const current = createMemo(() => + settings.general.newLayoutDesigns() + ? undefined + : (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]), + ) + const sorted = createMemo(() => { + const raw = items() + const list = settings.general.newLayoutDesigns() + ? raw + : raw.filter((item) => global.ensureServerCtx(item).sdk.protocolKind() !== "v2") + if (!list.length) return list + const active = current() + const order = new Map(list.map((item, index) => [item, index] as const)) + const rank = (value?: ServerHealth) => { + if (value?.healthy === true) return 0 + if (value?.healthy === false) return 2 + return 1 + } + return list.slice().sort((a, b) => { + if (a === active) return -1 + if (b === active) return 1 + const diff = + rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)]) + if (diff !== 0) return diff + return (order.get(a) ?? 0) - (order.get(b) ?? 0) + }) + }) + + return { + collection: { + items: sorted, + current, + health: () => global.servers.health, + }, + ...actions, + } +} + +export type ServerCollectionController = ReturnType + +export function useServerDomainController(options: { onSelect?: () => void } = {}) { + const navigate = useNavigate() + const server = useServer() + const global = useGlobal() + const collection = useServerCollectionController() + + const select = async (connection: ServerConnection.Any) => { + if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return + options.onSelect?.() + navigate("/") + queueMicrotask(() => server.setActive(ServerConnection.key(connection))) + } + + return { ...collection, selection: { select } } +} + +export type ServerDomainController = ReturnType + +export function useServerFormController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) { + const navigate = useNavigate() + const server = useServer() + const global = useGlobal() + const platform = usePlatform() + const language = useLanguage() + const settings = useSettings() + const mutations = useServerMutations() + const checkServerHealth = useCheckServerHealth() + const healthPreview = createServerHealthPreview(checkServerHealth) + const [store, setStore] = createStore({ + mode: "list" as FormMode, + originalUrl: undefined as string | undefined, + values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" }, + error: "", + status: undefined as boolean | undefined, + }) + + onCleanup(healthPreview.cancel) + + const reset = () => { + healthPreview.cancel() + setStore({ + mode: "list", + originalUrl: undefined, + values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" }, + error: "", + status: undefined, + }) + } + const allServers = () => { + if (!server.current || server.list.includes(server.current)) return server.list + return [server.current, ...server.list] + } + const editing = createMemo(() => + allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl), + ) + + const request = useMutation(() => ({ + mutationFn: async () => { + const normalized = normalizeServerUrl(store.values.url) + if (!normalized) { + reset() + return + } + + const original = store.mode === "edit" ? editing() : undefined + if (store.mode === "edit" && !original) return + const name = store.values.name.trim() || undefined + const username = store.values.username || undefined + const password = store.values.password || undefined + if ( + original?.type === "http" && + normalized === original.http.url && + name === original.displayName && + username === original.http.username && + password === original.http.password + ) { + reset() + return + } + + const connection: ServerConnection.Http = { + type: "http", + displayName: name, + http: { + url: normalized, + username: store.mode === "add" && !password ? undefined : username, + password, + }, + } + const result = await checkServerHealth(connection.http) + if (!result.healthy) { + setStore("error", language.t("dialog.server.add.error")) + return + } + if ( + !settings.general.newLayoutDesigns() && + (await detectServerProtocol(connection.http, platform.fetch ?? globalThis.fetch)) === "v2" + ) { + setStore("error", language.t("dialog.server.add.error")) + return + } + + if (original?.type === "http") { + if (normalized === original.http.url) mutations.add(connection) + if (normalized !== original.http.url) mutations.replace(original, connection) + reset() + return + } + + reset() + if (options.navigateOnAdd === false) { + mutations.add(connection) + options.onSelect?.() + return + } + mutations.add(connection) + options.onSelect?.() + navigate("/") + }, + })) + + const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status)) + const change = (field: keyof ServerFormValues, value: string) => { + if (request.isPending) return + setStore("values", field, value) + setStore("error", "") + if (field !== "name") preview() + } + const startAdd = () => { + reset() + setStore("mode", "add") + } + const startEdit = (connection: ServerConnection.Http) => { + reset() + setStore({ + mode: "edit", + originalUrl: connection.http.url, + values: { + url: connection.http.url, + name: connection.displayName ?? "", + username: connection.http.username ?? "", + password: connection.http.password ?? "", + }, + error: "", + status: global.servers.health[ServerConnection.key(connection)]?.healthy, + }) + } + const submit = () => { + if (store.mode === "list" || request.isPending) return + setStore("error", "") + request.mutate() + } + + createEffect(() => { + if (store.mode !== "edit") return + if (editing()) return + reset() + }) + + return { + state: { + mode: () => store.mode, + open: () => store.mode !== "list", + adding: () => store.mode === "add", + busy: () => request.isPending, + value: () => store.values.url, + name: () => store.values.name, + username: () => store.values.username, + password: () => store.values.password, + error: () => store.error, + status: () => store.status, + }, + change: { + value: (value: string) => change("url", value), + name: (value: string) => change("name", value), + username: (value: string) => change("username", value), + password: (value: string) => change("password", value), + }, + start: { add: startAdd, edit: startEdit }, + reset, + submit, + } +} + +export type ServerFormController = ReturnType diff --git a/packages/app/src/components/server/server-management.test.ts b/packages/app/src/components/server/server-management.test.ts new file mode 100644 index 0000000000..3f3b217e97 --- /dev/null +++ b/packages/app/src/components/server/server-management.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" +import { ServerConnection } from "@/context/server" +import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management" + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +const values = (url: string): ServerFormValues => ({ url, name: "", username: "opencode", password: "" }) + +describe("createServerHealthPreview", () => { + test("ignores an older response that resolves after the latest response", async () => { + const first = deferred<{ healthy: boolean }>() + const second = deferred<{ healthy: boolean }>() + const requests = [first, second] + const status: Array = [] + const preview = createServerHealthPreview(() => requests.shift()!.promise) + + const older = preview.preview(values("old.example.com"), (value) => status.push(value)) + const latest = preview.preview(values("new.example.com"), (value) => status.push(value)) + second.resolve({ healthy: true }) + await latest + first.resolve({ healthy: false }) + await older + + expect(status).toEqual([undefined, undefined, true]) + }) + + test("an incomplete value invalidates an in-flight response", async () => { + const request = deferred<{ healthy: boolean }>() + const status: Array = [] + const preview = createServerHealthPreview(() => request.promise) + + const pending = preview.preview(values("server.example.com"), (value) => status.push(value)) + await preview.preview(values("server"), (value) => status.push(value)) + request.resolve({ healthy: true }) + await pending + + expect(status).toEqual([undefined, undefined]) + }) + + test("cancellation prevents an in-flight response from updating status", async () => { + const request = deferred<{ healthy: boolean }>() + const status: Array = [] + const preview = createServerHealthPreview(() => request.promise) + + const pending = preview.preview(values("server.example.com"), (value) => status.push(value)) + preview.cancel() + request.resolve({ healthy: true }) + await pending + + expect(status).toEqual([undefined]) + }) +}) + +describe("replaceServerConnection", () => { + const original: ServerConnection.Http = { type: "http", http: { url: "https://old.example.com" } } + const next: ServerConnection.Http = { type: "http", http: { url: "https://new.example.com" } } + + test("moves active selection after adding the replacement and removes the original", () => { + const calls: string[] = [] + + replaceServerConnection(original, next, { + active: () => ServerConnection.key(original), + removeTabs: (key) => calls.push(`tabs:${key}`), + add: (server) => { + calls.push(`add:${ServerConnection.key(server)}`) + return server + }, + setActive: (key) => calls.push(`active:${key}`), + remove: (key) => calls.push(`remove:${key}`), + }) + + expect(calls).toEqual([ + "tabs:https://old.example.com", + "add:https://new.example.com", + "active:https://new.example.com", + "remove:https://old.example.com", + ]) + }) + + test("keeps the original when the replacement cannot be added", () => { + const removed: ServerConnection.Key[] = [] + + replaceServerConnection(original, next, { + active: () => ServerConnection.key(original), + removeTabs: () => {}, + add: () => undefined, + setActive: () => {}, + remove: (key) => removed.push(key), + }) + + expect(removed).toEqual([]) + }) +}) diff --git a/packages/app/src/components/server/server-management.ts b/packages/app/src/components/server/server-management.ts new file mode 100644 index 0000000000..d6a97ce40c --- /dev/null +++ b/packages/app/src/components/server/server-management.ts @@ -0,0 +1,60 @@ +import { normalizeServerUrl, ServerConnection } from "@/context/server" +import type { ServerHealth } from "@/utils/server-health" + +export type ServerFormValues = { + url: string + name: string + username: string + password: string +} + +export function createServerHealthPreview( + check: (server: ServerConnection.HttpBase) => Promise>, +) { + let generation = 0 + + const cancel = () => { + generation += 1 + } + + const preview = async (values: ServerFormValues, setStatus: (value: boolean | undefined) => void) => { + const current = ++generation + setStatus(undefined) + const normalized = normalizeServerUrl(values.url) + if (!normalized) return + const host = normalized.replace(/^https?:\/\//, "").split("/")[0] + if (!host) return + if (!host.includes("localhost") && !host.startsWith("127.0.0.1") && !host.includes(".") && !host.includes(":")) + return + + const http: ServerConnection.HttpBase = { url: normalized } + if (values.username) http.username = values.username + if (values.password) http.password = values.password + const result = await check(http) + if (current !== generation) return + setStatus(result.healthy) + } + + return { cancel, preview } +} + +export function replaceServerConnection( + original: ServerConnection.Http, + next: ServerConnection.Http, + operations: { + active: () => ServerConnection.Key | undefined + removeTabs: (key: ServerConnection.Key) => void + add: (server: ServerConnection.Http) => ServerConnection.Any | undefined + setActive: (key: ServerConnection.Key) => void + remove: (key: ServerConnection.Key) => void + }, +) { + const originalKey = ServerConnection.key(original) + const active = operations.active() + operations.removeTabs(originalKey) + const added = operations.add(next) + if (!added) return + const nextActive = active === originalKey ? ServerConnection.key(added) : active + if (nextActive) operations.setActive(nextActive) + operations.remove(originalKey) +} diff --git a/packages/app/src/components/server/server-row-menu.tsx b/packages/app/src/components/server/server-row-menu.tsx index ee4a46db07..619c8a50be 100644 --- a/packages/app/src/components/server/server-row-menu.tsx +++ b/packages/app/src/components/server/server-row-menu.tsx @@ -2,13 +2,13 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { type Component, Show } from "solid-js" -import { useServerManagementController } from "@/components/dialog-select-server" +import type { ServerActionsController } from "@/components/server/server-management-controller" import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" export const ServerRowMenu: Component<{ server: ServerConnection.Any - controller: ReturnType + domain: ServerActionsController onEdit: (server: ServerConnection.Http) => void open?: boolean onOpenChange?: (open: boolean) => void @@ -19,13 +19,13 @@ export const ServerRowMenu: Component<{ props.controller.setDefault(key)} - onRemoveDefault={() => props.controller.setDefault(null)} - onRemove={() => props.controller.handleRemove(key)} + onSetDefault={() => props.domain.defaults.set(key)} + onRemoveDefault={() => props.domain.defaults.set(null)} + onRemove={() => props.domain.connection.remove(key)} open={props.open} onOpenChange={props.onOpenChange} /> diff --git a/packages/app/src/components/settings-servers.tsx b/packages/app/src/components/settings-servers.tsx index 299d41f6db..f727ceb5cd 100644 --- a/packages/app/src/components/settings-servers.tsx +++ b/packages/app/src/components/settings-servers.tsx @@ -1,16 +1,19 @@ import { Show, type Component } from "solid-js" +import { IconButton } from "@opencode-ai/ui/icon-button" import { useLanguage } from "@/context/language" -import { ServerConnectionForm, ServerConnectionList, useServerManagementController } from "./dialog-select-server" +import { useServerDomainController, useServerFormController } from "./server/server-management-controller" +import { ServerConnectionForm, ServerConnectionList } from "./dialog-select-server" export const SettingsServers: Component = () => { const language = useLanguage() - const controller = useServerManagementController() + const domain = useServerDomainController() + const form = useServerFormController() return (
@@ -18,13 +21,25 @@ export const SettingsServers: Component = () => {

{language.t("status.popover.tab.servers")}

- + } >
-
{controller.formTitle()}
- +
+
+ + + {form.state.adding() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")} + +
+
+
diff --git a/packages/app/src/components/settings-v2/dialog-server-v2.tsx b/packages/app/src/components/settings-v2/dialog-server-v2.tsx index 7113cdcf0a..ad5a43287f 100644 --- a/packages/app/src/components/settings-v2/dialog-server-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-server-v2.tsx @@ -6,7 +6,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js" import { useLanguage } from "@/context/language" import { type ServerConnection } from "@/context/server" -import { useServerManagementController } from "../dialog-select-server" +import { useServerFormController } from "../server/server-management-controller" import "./settings-v2.css" export const DialogServerV2: Component<{ @@ -15,39 +15,39 @@ export const DialogServerV2: Component<{ }> = (props) => { const dialog = useDialog() const language = useLanguage() - const controller = useServerManagementController({ + const form = useServerFormController({ onSelect: () => dialog.close(), navigateOnAdd: false, }) const [opened, setOpened] = createSignal(false) onMount(() => { - if (props.mode === "add") controller.startAdd() - if (props.mode === "edit" && props.server) controller.startEdit(props.server) + if (props.mode === "add") form.start.add() + if (props.mode === "edit" && props.server) form.start.edit(props.server) setOpened(true) }) onCleanup(() => { - controller.resetForm() + form.reset() }) createEffect(() => { if (!opened()) return - if (controller.isFormMode()) return + if (form.state.open()) return dialog.close() }) const keyDown = (event: KeyboardEvent) => { if (event.key !== "Enter" || event.isComposing) return event.preventDefault() - controller.submitForm() + form.submit() } const title = () => props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title") const submitLabel = () => { - if (controller.formBusy()) return language.t("dialog.server.add.checking") + if (form.state.busy()) return language.t("dialog.server.add.checking") if (props.mode === "add") return language.t("dialog.server.add.button") return language.t("common.save") } @@ -66,16 +66,16 @@ export const DialogServerV2: Component<{ type="text" appearance="large" class="!w-full self-stretch" - value={controller.formValue()} + value={form.state.value()} placeholder={language.t("dialog.server.add.placeholder")} - invalid={!!controller.formError()} - disabled={controller.formBusy()} + invalid={!!form.state.error()} + disabled={form.state.busy()} autofocus - onInput={(event) => controller.handleFormChange()(event.currentTarget.value)} + onInput={(event) => form.change.value(event.currentTarget.value)} onKeyDown={keyDown} /> - - {controller.formError()} + + {form.state.error()}
@@ -84,10 +84,10 @@ export const DialogServerV2: Component<{ type="text" appearance="large" class="!w-full self-stretch" - value={controller.formName()} + value={form.state.name()} placeholder={language.t("dialog.server.add.namePlaceholder")} - disabled={controller.formBusy()} - onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)} + disabled={form.state.busy()} + onInput={(event) => form.change.name(event.currentTarget.value)} onKeyDown={keyDown} />
@@ -98,10 +98,10 @@ export const DialogServerV2: Component<{ type="text" appearance="large" class="!w-full self-stretch" - value={controller.formUsername()} + value={form.state.username()} placeholder={language.t("dialog.server.add.usernamePlaceholder")} - disabled={controller.formBusy()} - onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)} + disabled={form.state.busy()} + onInput={(event) => form.change.username(event.currentTarget.value)} onKeyDown={keyDown} />
@@ -111,10 +111,10 @@ export const DialogServerV2: Component<{ type="password" appearance="large" class="!w-full self-stretch" - value={controller.formPassword()} + value={form.state.password()} placeholder={language.t("dialog.server.add.passwordPlaceholder")} - disabled={controller.formBusy()} - onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)} + disabled={form.state.busy()} + onInput={(event) => form.change.password(event.currentTarget.value)} onKeyDown={keyDown} />
@@ -122,10 +122,10 @@ export const DialogServerV2: Component<{ - dialog.close()}> + dialog.close()}> {language.t("common.cancel")} - + {submitLabel()} diff --git a/packages/app/src/components/settings-v2/servers.tsx b/packages/app/src/components/settings-v2/servers.tsx index b93f700d24..234df57ef3 100644 --- a/packages/app/src/components/settings-v2/servers.tsx +++ b/packages/app/src/components/settings-v2/servers.tsx @@ -10,7 +10,7 @@ import { ServerRowMenu } from "@/components/server/server-row-menu" import { ServerHealthIndicator } from "@/components/server/server-row" import { useLanguage } from "@/context/language" import { ServerConnection, serverName } from "@/context/server" -import { useServerManagementController } from "../dialog-select-server" +import { useServerCollectionController } from "../server/server-management-controller" import { DialogServerV2 } from "./dialog-server-v2" import { SettingsListV2 } from "./parts/list" import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings" @@ -19,16 +19,16 @@ import "./settings-v2.css" export const SettingsServersV2: Component = () => { const dialog = useDialog() const language = useLanguage() - const controller = useServerManagementController() + const domain = useServerCollectionController() const [store, setStore] = createStore({ filter: "" }) const wslServers = useFilteredWslServers(() => store.filter) const showSearch = createMemo( - () => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1, + () => domain.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1, ) const filtered = createMemo(() => { - const items = controller.sortedItems().filter((item) => !isWslServer(item)) + const items = domain.collection.items().filter((item) => !isWslServer(item)) const query = store.filter.trim() if (!query) return items return fuzzysort @@ -39,11 +39,11 @@ export const SettingsServersV2: Component = () => { }) const openAdd = () => { - dialog.push(() => ) + void dialog.push(() => ) } const openEdit = (server: ServerConnection.Http) => { - dialog.push(() => ) + void dialog.push(() => ) } return ( @@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => { } > - + {(item) => { const key = ServerConnection.key(item) - const health = () => controller.status()[key] - const isDefault = () => controller.defaultKey() === key + const health = () => domain.collection.health()[key] + const isDefault = () => domain.defaults.key() === key return (
@@ -122,10 +122,10 @@ export const SettingsServersV2: Component = () => {
- + {language.t("dialog.server.status.default")} - +
) diff --git a/packages/app/src/pages/home/home-projects-controller.tsx b/packages/app/src/pages/home/home-projects-controller.tsx index 888f4ede72..e1d1390d8e 100644 --- a/packages/app/src/pages/home/home-projects-controller.tsx +++ b/packages/app/src/pages/home/home-projects-controller.tsx @@ -1,5 +1,5 @@ import { useDirectoryPicker } from "@/components/directory-picker" -import { useServerManagementController } from "@/components/dialog-select-server" +import { useServerActionsController } from "@/components/server/server-management-controller" import { useSettingsCommand } from "@/components/settings-dialog" import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" import { type LocalProject } from "@/context/layout" @@ -22,7 +22,7 @@ export function createHomeProjectsController(home: HomeController) { const language = useLanguage() const notification = useNotification() const openSettings = useSettingsCommand() - const serverManagement = useServerManagementController({ navigateOnAdd: false }) + const serverManagement = useServerActionsController() const [_state, setState, _, ready] = persisted( Persist.global("home.servers", ["home.servers.v1"]), createStore({ collapsed: {} as Record }), @@ -56,12 +56,12 @@ export function createHomeProjectsController(home: HomeController) { const key = ServerConnection.key(conn) setState("collapsed", key, !state().collapsed[key]) }, - canDefault: serverManagement.canDefault, - defaultKey: serverManagement.defaultKey, + canDefault: serverManagement.defaults.available, + defaultKey: serverManagement.defaults.key, setDefault: (conn: ServerConnection.Any | undefined) => - serverManagement.setDefault(conn ? ServerConnection.key(conn) : null), - canRemove: (conn: ServerConnection.Any) => serverManagement.canRemove(ServerConnection.key(conn)), - remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)), + serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null), + canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)), + remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)), edit: (conn: ServerConnection.Http) => dialog.show(() => ), focus: home.selection.focusServer, }, diff --git a/packages/app/src/wsl/settings.tsx b/packages/app/src/wsl/settings.tsx index 82616c6dcf..800c5bcd2d 100644 --- a/packages/app/src/wsl/settings.tsx +++ b/packages/app/src/wsl/settings.tsx @@ -7,7 +7,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { useMutation } from "@tanstack/solid-query" import fuzzysort from "fuzzysort" import { type Accessor, For, Show, createMemo } from "solid-js" -import type { useServerManagementController } from "@/components/dialog-select-server" +import type { ServerCollectionController } from "@/components/server/server-management-controller" import { ServerHealthIndicator } from "@/components/server/server-row" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -17,8 +17,6 @@ import { DialogAddWslServer } from "./dialog-add-server" import { useWslServers } from "./context" import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model" -type Controller = ReturnType - export function isWslServer(server: ServerConnection.Any) { return server.type === "sidecar" && server.variant === "wsl" } @@ -28,7 +26,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) { const dialog = useDialog() const language = useLanguage() const openAddWsl = () => { - dialog.push(() => ) + void dialog.push(() => ) } return ( ) { } export function WslServerSettings(props: { - controller: Controller + domain: Pick servers: ReturnType }) { const platform = usePlatform() @@ -86,7 +84,7 @@ export function WslServerSettings(props: { })) const remove = (key: ServerConnection.Key) => { - request.mutate(() => props.controller.handleRemove(key)) + request.mutate(() => props.domain.connection.remove(key)) } return ( @@ -100,7 +98,7 @@ export function WslServerSettings(props: { return (
- +
{item.config.distro} @@ -114,7 +112,7 @@ export function WslServerSettings(props: {
- + {language.t("dialog.server.status.default")} @@ -145,13 +143,13 @@ export function WslServerSettings(props: { {language.t("wsl.server.retryStart")} - - props.controller.setDefault(key)}> + + props.domain.defaults.set(key)}> {language.t("dialog.server.menu.default")} - - props.controller.setDefault(null)}> + + props.domain.defaults.set(null)}> {language.t("dialog.server.menu.defaultRemove")}