diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 607e0bb6aa45..7c31ccc61c3e 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,4 +1,3 @@ -import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -13,34 +12,20 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { showToast } from "@/utils/toast" -import { - type Accessor, - type Component, - createEffect, - createMemo, - createResource, - createUniqueId, - For, - Match, - onCleanup, - onMount, - Show, - Switch, -} from "solid-js" -import { createStore, produce } from "solid-js/store" +import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js" +import { createStore } from "solid-js/store" import { useParams } from "@solidjs/router" import { ExternalLink } from "@/components/external-link" -import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { popularProviders, useProviders } from "@/hooks/use-providers" import { CustomProviderForm } from "./dialog-custom-provider" import { decode64 } from "@/utils/base64" +import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller" const CUSTOM_ID = "_custom" -type ConnectMethod = Extract -type IntegrationForm = NonNullable[number] +type IntegrationForm = NonNullable[number] type StringForm = Extract export function useProviderConnectController(options: { onBack?: () => void } = {}) { @@ -385,120 +370,29 @@ function ProviderConnection(props: { }) { const dialog = useDialog() const serverSync = useServerSync() - const serverSDK = useServerSDK() const params = useParams() const language = useLanguage() const settings = useSettings() const newLayout = settings.general.newLayoutDesigns const providers = useProviders(() => props.directory?.()) const directory = () => props.directory?.() ?? decode64(params.dir) - const location = () => { - const value = directory() - return value ? { directory: value } : undefined - } - - const alive = { value: true } - const timer = { current: undefined as ReturnType | undefined } - - onCleanup(() => { - alive.value = false - if (timer.current === undefined) return - clearTimeout(timer.current) - timer.current = undefined - }) const provider = createMemo( () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, ) - const fallback = createMemo(() => [ - { - type: "key" as const, - label: language.t("provider.connect.method.apiKey"), + const controller = createProviderConnectionController({ + provider: () => props.provider, + directory, + onComplete: () => { + dialog.close() + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.connect.toast.connected.title", { provider: provider().name }), + description: language.t("provider.connect.toast.connected.description", { provider: provider().name }), + }) }, - ]) - const [integration] = createResource( - () => ({ provider: props.provider, directory: directory() }), - (input) => - serverSDK() - .api.integration.get({ - integrationID: input.provider, - location: input.directory ? { directory: input.directory } : undefined, - }) - .then((result) => result.data), - ) - const loading = createMemo(() => integration.loading) - const methods = createMemo(() => { - const values = integration.latest?.methods.filter( - (method): method is ConnectMethod => method.type === "key" || method.type === "oauth", - ) - return values?.length ? values : fallback() - }) - const [store, setStore] = createStore({ - methodIndex: undefined as undefined | number, - authorization: undefined as undefined | IntegrationOauthConnectOutput["data"], - formAnswer: undefined as FormAnswer | undefined, - state: "pending" as undefined | "pending" | "complete" | "error" | "form", - error: undefined as string | undefined, }) - - type Action = - | { type: "method.select"; index: number } - | { type: "method.reset" } - | { type: "auth.form" } - | { type: "auth.answer"; answer: FormAnswer | undefined } - | { type: "auth.pending" } - | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] } - | { type: "auth.error"; error: string } - - function dispatch(action: Action) { - setStore( - produce((draft) => { - if (action.type === "method.select") { - draft.methodIndex = action.index - draft.authorization = undefined - draft.formAnswer = undefined - draft.state = undefined - draft.error = undefined - return - } - if (action.type === "method.reset") { - draft.methodIndex = undefined - draft.authorization = undefined - draft.formAnswer = undefined - draft.state = undefined - draft.error = undefined - return - } - if (action.type === "auth.form") { - draft.state = "form" - draft.error = undefined - return - } - if (action.type === "auth.answer") { - draft.formAnswer = action.answer - draft.state = undefined - draft.error = undefined - return - } - if (action.type === "auth.pending") { - draft.state = "pending" - draft.error = undefined - return - } - if (action.type === "auth.complete") { - draft.state = "complete" - draft.authorization = action.authorization - draft.error = undefined - return - } - draft.state = "error" - draft.error = action.error - }), - ) - } - - const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined)) - const methodLabel = (value?: { type?: string; label?: string }) => { if (!value) return "" if (value.type === "key") return language.t("provider.connect.method.apiKey") @@ -520,65 +414,6 @@ function ProviderConnection(props: { } } - function formatError(value: unknown, fallback: string): string { - if (value && typeof value === "object" && "data" in value) { - const data = (value as { data?: { message?: unknown } }).data - if (typeof data?.message === "string" && data.message) return data.message - } - if (value && typeof value === "object" && "error" in value) { - const nested = formatError((value as { error?: unknown }).error, "") - if (nested) return nested - } - if (value && typeof value === "object" && "message" in value) { - const message = (value as { message?: unknown }).message - if (typeof message === "string" && message) return message - } - if (value instanceof Error && value.message) return value.message - if (typeof value === "string" && value) return value - return fallback - } - - async function selectMethod(index: number, answer?: FormAnswer) { - if (timer.current !== undefined) { - clearTimeout(timer.current) - timer.current = undefined - } - - const method = methods()[index] - dispatch({ type: "method.select", index }) - - if (method.form?.length && !answer) { - dispatch({ type: "auth.form" }) - return - } - if (method.type === "key") { - dispatch({ type: "auth.answer", answer }) - return - } - if (method.type === "oauth") { - if (method.form?.some((field) => field.type !== "string")) { - dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" }) - return - } - dispatch({ type: "auth.pending" }) - await serverSDK() - .api.integration.oauth.connect({ - integrationID: props.provider, - methodID: method.id, - ...(answer ? { answer } : {}), - location: location(), - }) - .then((x) => { - if (!alive.value) return - dispatch({ type: "auth.complete", authorization: x.data }) - }) - .catch((e) => { - if (!alive.value) return - dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) }) - }) - } - } - function AuthFormView() { const [formStore, setFormStore] = createStore({ value: {} as Record, @@ -586,7 +421,7 @@ function ProviderConnection(props: { }) const fields = createMemo(() => { - const value = method() + const value = controller.currentMethod() return (value?.form ?? []).flatMap((field) => (field.type === "string" ? [field] : [])) }) const matches = (field: StringForm, value: Record) => { @@ -599,7 +434,7 @@ function ProviderConnection(props: { const current = createMemo(() => { const all = fields() const index = all.findIndex((field, index) => index >= formStore.index && matches(field, formStore.value)) - if (index === -1) return + if (index === -1) return undefined return { index, field: all[index], @@ -613,13 +448,14 @@ function ProviderConnection(props: { }) async function next(index: number, value: Record) { - if (store.methodIndex === undefined) return + const selected = controller.methodIndex() + if (selected === undefined) return const next = fields().findIndex((field, i) => i > index && matches(field, value)) if (next !== -1) { setFormStore("index", next) return } - await selectMethod(store.methodIndex, value) + await controller.auth.select(selected, value) } async function handleSubmit(e: SubmitEvent) { @@ -633,12 +469,12 @@ function ProviderConnection(props: { const item = () => current() const text = createMemo(() => { const field = item()?.field - if (!field || field.options) return + if (!field || field.options) return undefined return field }) const select = createMemo(() => { const field = item()?.field - if (!field?.options) return + if (!field?.options) return undefined return field }) @@ -709,32 +545,9 @@ function ProviderConnection(props: { listRef?.onKeyDown(e) } - let auto = false - createEffect(() => { - if (auto) return - if (loading()) return - if (methods().length === 1) { - auto = true - void selectMethod(0) - } - }) - - async function complete() { - await serverSync() - .refreshProviders() - .catch(() => undefined) - dialog.close() - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("provider.connect.toast.connected.title", { provider: provider().name }), - description: language.t("provider.connect.toast.connected.description", { provider: provider().name }), - }) - } - function goBack() { - if (methods().length > 1 && store.methodIndex !== undefined) { - dispatch({ type: "method.reset" }) + if (controller.methods().length > 1 && controller.methodIndex() !== undefined) { + controller.auth.reset() return } props.onBack() @@ -750,14 +563,14 @@ function ProviderConnection(props: { {language.t("provider.connect.selectMethod", { provider: provider().name })}
- + {(item, index) => { const details = () => methodDetails(item) return (