diff --git a/packages/app/src/components/dialog-select-server-default.ts b/packages/app/src/components/dialog-select-server-default.ts new file mode 100644 index 00000000..56bbc563 --- /dev/null +++ b/packages/app/src/components/dialog-select-server-default.ts @@ -0,0 +1,78 @@ +import { createMemo, createResource } from "solid-js" +import { showToast } from "@opencode-ai/ui/toast" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { normalizeServerUrl, ServerConnection } from "@/context/server" +import { useCheckServerHealth } from "@/utils/server-health" + +export const DEFAULT_USERNAME = "opencode" + +function showRequestError(language: ReturnType, err: unknown) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + +export 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, canDefault, setDefault } +} + +export 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 } +} diff --git a/packages/app/src/components/dialog-select-server-form.tsx b/packages/app/src/components/dialog-select-server-form.tsx new file mode 100644 index 00000000..e31ba3b4 --- /dev/null +++ b/packages/app/src/components/dialog-select-server-form.tsx @@ -0,0 +1,84 @@ +import { TextField } from "@opencode-ai/ui/text-field" +import { useLanguage } from "@/context/language" + +export interface ServerFormProps { + value: string + name: string + username: string + password: string + placeholder: string + busy: boolean + error: string + status: boolean | undefined + onChange: (value: string) => void + onNameChange: (value: string) => void + onUsernameChange: (value: string) => void + onPasswordChange: (value: string) => void + onSubmit: () => void + onBack: () => void +} + +export function ServerForm(props: ServerFormProps) { + const language = useLanguage() + const keyDown = (event: KeyboardEvent) => { + event.stopPropagation() + if (event.key === "Escape") { + event.preventDefault() + props.onBack() + return + } + if (event.key !== "Enter" || event.isComposing) return + event.preventDefault() + props.onSubmit() + } + + return ( +
+
+
+ +
+ +
+ + +
+
+
+ ) +} diff --git a/packages/app/src/components/dialog-select-server-list.tsx b/packages/app/src/components/dialog-select-server-list.tsx new file mode 100644 index 00000000..bafcf302 --- /dev/null +++ b/packages/app/src/components/dialog-select-server-list.tsx @@ -0,0 +1,121 @@ +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { List } from "@opencode-ai/ui/list" +import { Show } from "solid-js" +import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" +import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import { type ServerHealth } from "@/utils/server-health" + +interface ServerConnectionListProps { + items: () => ServerConnection.Any[] + current: () => ServerConnection.Any | undefined + status: Record + defaultKey: () => ServerConnection.Key | null | undefined + canDefault: () => boolean + setDefault: (key: ServerConnection.Key | null) => void | Promise + onEdit: (conn: ServerConnection.Http) => void + onRemove: (key: ServerConnection.Key) => void | Promise + onSelect: (conn: ServerConnection.Any) => void | Promise +} + +export function ServerConnectionList(props: ServerConnectionListProps) { + const language = useLanguage() + const currentKey = () => { + const current = props.current() + return current ? ServerConnection.key(current) : undefined + } + + return ( + { + if (x) props.onSelect(x) + }} + divider={true} + class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-[var(--radius-md)] [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent" + > + {(i) => { + const key = ServerConnection.key(i) + return ( +
+
+ +
+ + + {language.t("dialog.server.status.default")} + + + } + showCredentials + /> +
+ + + + + + + e.stopPropagation()} + onPointerDown={(e: PointerEvent) => e.stopPropagation()} + /> + + + { + if (i.type !== "http") return + props.onEdit(i) + }} + > + {language.t("dialog.server.menu.edit")} + + + props.setDefault(key)}> + {language.t("dialog.server.menu.default")} + + + + props.setDefault(null)}> + + {language.t("dialog.server.menu.defaultRemove")} + + + + + props.onRemove(ServerConnection.key(i))} + class="text-error-text hover:bg-error-bg" + > + {language.t("dialog.server.menu.delete")} + + + + + +
+
+ ) + }} +
+ ) +} diff --git a/packages/app/src/components/dialog-select-server-source.test.ts b/packages/app/src/components/dialog-select-server-source.test.ts new file mode 100644 index 00000000..9489fd23 --- /dev/null +++ b/packages/app/src/components/dialog-select-server-source.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" + +const dialogSource = readFileSync(new URL("./dialog-select-server.tsx", import.meta.url), "utf8") +const defaultsSource = readFileSync(new URL("./dialog-select-server-default.ts", import.meta.url), "utf8") +const formSource = readFileSync(new URL("./dialog-select-server-form.tsx", import.meta.url), "utf8") +const listSource = readFileSync(new URL("./dialog-select-server-list.tsx", import.meta.url), "utf8") + +describe("dialog-select-server source boundary", () => { + test("keeps server form, list, and preview/default hooks in their owner files", () => { + expect(dialogSource).toContain("ServerForm") + expect(dialogSource).toContain("ServerConnectionList") + expect(dialogSource).toContain("useDefaultServer") + expect(dialogSource).toContain("useServerPreview") + expect(defaultsSource).toContain("export function useDefaultServer") + expect(defaultsSource).toContain("export function useServerPreview") + expect(formSource).toContain("export function ServerForm") + expect(listSource).toContain("export function ServerConnectionList") + + for (const ownerImplementation of [ + "export function useDefaultServer", + "export function useServerPreview", + "export function ServerForm", + "export function ServerConnectionList", + "createResource", + "TextField", + "DropdownMenu", + ]) { + expect(dialogSource).not.toContain(ownerImplementation) + } + }) + + test("preserves add/edit fields and list menu actions after extraction", () => { + for (const key of [ + "dialog.server.add.url", + "dialog.server.add.name", + "dialog.server.add.username", + "dialog.server.add.password", + ]) { + expect(formSource).toContain(key) + } + + for (const key of [ + "dialog.server.menu.edit", + "dialog.server.menu.default", + "dialog.server.menu.defaultRemove", + "dialog.server.menu.delete", + "dialog.server.status.default", + ]) { + expect(listSource).toContain(key) + } + }) +}) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index f004fae1..bd6553c5 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -1,175 +1,18 @@ import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -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 "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" -import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" -import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" - -const DEFAULT_USERNAME = "opencode" - -interface ServerFormProps { - value: string - name: string - username: string - password: string - placeholder: string - busy: boolean - error: string - status: boolean | undefined - onChange: (value: string) => void - onNameChange: (value: string) => void - onUsernameChange: (value: string) => void - onPasswordChange: (value: string) => void - onSubmit: () => void - 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, 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) => { - event.stopPropagation() - if (event.key === "Escape") { - event.preventDefault() - props.onBack() - return - } - if (event.key !== "Enter" || event.isComposing) return - event.preventDefault() - props.onSubmit() - } - - return ( -
-
-
- -
- -
- - -
-
-
- ) -} +import { DEFAULT_USERNAME, useDefaultServer, useServerPreview } from "./dialog-select-server-default" +import { ServerForm } from "./dialog-select-server-form" +import { ServerConnectionList } from "./dialog-select-server-list" export function DialogSelectServer() { const navigate = useNavigate() @@ -526,97 +369,17 @@ export function DialogSelectServer() { /> } > - x.http.url} - onSelect={(x) => { - if (x) select(x) - }} - divider={true} - class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent" - > - {(i) => { - const key = ServerConnection.key(i) - return ( -
-
- -
- - - {language.t("dialog.server.status.default")} - - - } - showCredentials - /> -
- - - - - - - e.stopPropagation()} - onPointerDown={(e: PointerEvent) => e.stopPropagation()} - /> - - - { - if (i.type !== "http") return - startEdit(i) - }} - > - {language.t("dialog.server.menu.edit")} - - - setDefault(key)}> - - {language.t("dialog.server.menu.default")} - - - - - setDefault(null)}> - - {language.t("dialog.server.menu.defaultRemove")} - - - - - handleRemove(ServerConnection.key(i))} - class="text-error-text hover:bg-error-bg" - > - {language.t("dialog.server.menu.delete")} - - - - - -
-
- ) - }} -
+ current={current} + status={store.status} + defaultKey={defaultKey} + canDefault={canDefault} + setDefault={setDefault} + onEdit={startEdit} + onRemove={handleRemove} + onSelect={select} + />