Skip to content

Commit 38bb38e

Browse files
authored
refactor(app): unify provider connect dialog (#35518)
1 parent 3a149ba commit 38bb38e

12 files changed

Lines changed: 441 additions & 402 deletions

‎packages/app/src/components/dialog-connect-provider.tsx‎

Lines changed: 208 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,167 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
77
import { List, type ListRef } from "@opencode-ai/ui/list"
88
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
99
import { Spinner } from "@opencode-ai/ui/spinner"
10+
import { Tag } from "@opencode-ai/ui/tag"
1011
import { TextField } from "@opencode-ai/ui/text-field"
1112
import { showToast } from "@/utils/toast"
12-
import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
13+
import {
14+
type Accessor,
15+
type Component,
16+
createEffect,
17+
createMemo,
18+
createResource,
19+
Match,
20+
onCleanup,
21+
onMount,
22+
Show,
23+
Switch,
24+
} from "solid-js"
1325
import { createStore, produce } from "solid-js/store"
1426
import { Link } from "@/components/link"
1527
import { useServerSDK } from "@/context/server-sdk"
1628
import { useServerSync } from "@/context/server-sync"
1729
import { useLanguage } from "@/context/language"
18-
import { useProviders } from "@/hooks/use-providers"
30+
import { popularProviders, useProviders } from "@/hooks/use-providers"
31+
import { CustomProviderForm } from "./dialog-custom-provider"
1932

20-
export function DialogConnectProvider(props: {
33+
const CUSTOM_ID = "_custom"
34+
35+
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
36+
const [store, setStore] = createStore({ selected: undefined as string | undefined })
37+
const reset = () => setStore("selected", undefined)
38+
39+
return {
40+
selected: () => store.selected,
41+
select: (provider?: string) => setStore("selected", provider),
42+
back: options.onBack ?? reset,
43+
}
44+
}
45+
46+
export const DialogConnectProvider: Component<{
47+
directory?: Accessor<string | undefined>
48+
controller?: ReturnType<typeof useProviderConnectController>
49+
}> = (props) => {
50+
const fallback = useProviderConnectController()
51+
const controller = props.controller ?? fallback
52+
const language = useLanguage()
53+
const reset = controller.back
54+
const back = { current: reset }
55+
const select = (provider?: string) => {
56+
back.current = reset
57+
controller.select(provider)
58+
}
59+
60+
return (
61+
<Dialog
62+
class="h-full"
63+
transition
64+
title={
65+
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
66+
<IconButton
67+
tabIndex={-1}
68+
icon="arrow-left"
69+
variant="ghost"
70+
onClick={() => back.current()}
71+
aria-label={language.t("common.goBack")}
72+
/>
73+
</Show>
74+
}
75+
>
76+
<Switch>
77+
<Match when={controller.selected() === CUSTOM_ID}>
78+
<CustomProviderForm />
79+
</Match>
80+
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
81+
{(provider) => (
82+
<ProviderConnection
83+
provider={provider()}
84+
directory={props.directory}
85+
onBack={reset}
86+
setBack={(handler) => (back.current = handler)}
87+
/>
88+
)}
89+
</Match>
90+
<Match when={true}>
91+
<ProviderPicker directory={props.directory} onSelect={select} />
92+
</Match>
93+
</Switch>
94+
</Dialog>
95+
)
96+
}
97+
98+
function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
99+
const providers = useProviders(props.directory)
100+
const language = useLanguage()
101+
const popularGroup = () => language.t("dialog.provider.group.popular")
102+
const otherGroup = () => language.t("dialog.provider.group.other")
103+
const customLabel = () => language.t("settings.providers.tag.custom")
104+
const note = (id: string) => {
105+
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
106+
if (id === "openai") return language.t("dialog.provider.openai.note")
107+
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
108+
if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
109+
return undefined
110+
}
111+
112+
return (
113+
<List
114+
class="px-3"
115+
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
116+
emptyMessage={language.t("dialog.provider.empty")}
117+
activeIcon="plus-small"
118+
key={(x) => x?.id}
119+
items={() => {
120+
language.locale()
121+
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
122+
}}
123+
filterKeys={["id", "name"]}
124+
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
125+
sortBy={(a, b) => {
126+
if (a.id === CUSTOM_ID) return -1
127+
if (b.id === CUSTOM_ID) return 1
128+
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
129+
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
130+
return a.name.localeCompare(b.name)
131+
}}
132+
sortGroupsBy={(a, b) => {
133+
const popular = popularGroup()
134+
if (a.category === popular && b.category !== popular) return -1
135+
if (b.category === popular && a.category !== popular) return 1
136+
return 0
137+
}}
138+
onSelect={(x) => {
139+
if (!x) return
140+
props.onSelect(x.id)
141+
}}
142+
>
143+
{(i) => (
144+
<div class="px-1.25 w-full flex items-center gap-x-3">
145+
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
146+
<span>{i.name}</span>
147+
<Show when={i.id === "opencode"}>
148+
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
149+
</Show>
150+
<Show when={i.id === CUSTOM_ID}>
151+
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
152+
</Show>
153+
<Show when={i.id === "opencode"}>
154+
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
155+
</Show>
156+
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
157+
<Show when={i.id === "opencode-go"}>
158+
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
159+
</Show>
160+
</div>
161+
)}
162+
</List>
163+
)
164+
}
165+
166+
function ProviderConnection(props: {
21167
provider: string
22168
directory?: Accessor<string | undefined>
23169
onBack: () => void
170+
setBack: (handler: () => void) => void
24171
}) {
25172
const dialog = useDialog()
26173
const serverSync = useServerSync()
@@ -369,6 +516,8 @@ export function DialogConnectProvider(props: {
369516
props.onBack()
370517
}
371518

519+
props.setBack(goBack)
520+
372521
function MethodSelection() {
373522
return (
374523
<>
@@ -589,81 +738,67 @@ export function DialogConnectProvider(props: {
589738
}
590739

591740
return (
592-
<Dialog
593-
class="h-full"
594-
title={
595-
<IconButton
596-
tabIndex={-1}
597-
icon="arrow-left"
598-
variant="ghost"
599-
onClick={goBack}
600-
aria-label={language.t("common.goBack")}
601-
/>
602-
}
603-
transition
604-
>
605-
<div class="flex flex-col gap-6 px-2.5 pb-3">
606-
<div class="px-2.5 flex gap-4 items-center">
607-
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
608-
<div class="text-16-medium text-text-strong">
609-
<Switch>
610-
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
611-
{language.t("provider.connect.title.anthropicProMax")}
612-
</Match>
613-
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
614-
</Switch>
615-
</div>
741+
<div class="flex flex-col gap-6 px-2.5 pb-3">
742+
<div class="px-2.5 flex gap-4 items-center">
743+
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
744+
<div class="text-16-medium text-text-strong">
745+
<Switch>
746+
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
747+
{language.t("provider.connect.title.anthropicProMax")}
748+
</Match>
749+
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
750+
</Switch>
616751
</div>
617-
<div class="px-2.5 pb-10 flex flex-col gap-6">
618-
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
619-
<Switch>
620-
<Match when={loading()}>
621-
<div class="text-14-regular text-text-base">
622-
<div class="flex items-center gap-x-2">
623-
<Spinner />
624-
<span>{language.t("provider.connect.status.inProgress")}</span>
625-
</div>
752+
</div>
753+
<div class="px-2.5 pb-10 flex flex-col gap-6">
754+
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
755+
<Switch>
756+
<Match when={loading()}>
757+
<div class="text-14-regular text-text-base">
758+
<div class="flex items-center gap-x-2">
759+
<Spinner />
760+
<span>{language.t("provider.connect.status.inProgress")}</span>
626761
</div>
627-
</Match>
628-
<Match when={store.methodIndex === undefined}>
629-
<MethodSelection />
630-
</Match>
631-
<Match when={store.state === "pending"}>
632-
<div class="text-14-regular text-text-base">
633-
<div class="flex items-center gap-x-2">
634-
<Spinner />
635-
<span>{language.t("provider.connect.status.inProgress")}</span>
636-
</div>
762+
</div>
763+
</Match>
764+
<Match when={store.methodIndex === undefined}>
765+
<MethodSelection />
766+
</Match>
767+
<Match when={store.state === "pending"}>
768+
<div class="text-14-regular text-text-base">
769+
<div class="flex items-center gap-x-2">
770+
<Spinner />
771+
<span>{language.t("provider.connect.status.inProgress")}</span>
637772
</div>
638-
</Match>
639-
<Match when={store.state === "prompt"}>
640-
<AuthPromptsView />
641-
</Match>
642-
<Match when={store.state === "error"}>
643-
<div class="text-14-regular text-text-base">
644-
<div class="flex items-center gap-x-2">
645-
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
646-
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
647-
</div>
773+
</div>
774+
</Match>
775+
<Match when={store.state === "prompt"}>
776+
<AuthPromptsView />
777+
</Match>
778+
<Match when={store.state === "error"}>
779+
<div class="text-14-regular text-text-base">
780+
<div class="flex items-center gap-x-2">
781+
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
782+
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
648783
</div>
649-
</Match>
650-
<Match when={method()?.type === "api"}>
651-
<ApiAuthView />
652-
</Match>
653-
<Match when={method()?.type === "oauth"}>
654-
<Switch>
655-
<Match when={store.authorization?.method === "code"}>
656-
<OAuthCodeView />
657-
</Match>
658-
<Match when={store.authorization?.method === "auto"}>
659-
<OAuthAutoView />
660-
</Match>
661-
</Switch>
662-
</Match>
663-
</Switch>
664-
</div>
784+
</div>
785+
</Match>
786+
<Match when={method()?.type === "api"}>
787+
<ApiAuthView />
788+
</Match>
789+
<Match when={method()?.type === "oauth"}>
790+
<Switch>
791+
<Match when={store.authorization?.method === "code"}>
792+
<OAuthCodeView />
793+
</Match>
794+
<Match when={store.authorization?.method === "auto"}>
795+
<OAuthAutoView />
796+
</Match>
797+
</Switch>
798+
</Match>
799+
</Switch>
665800
</div>
666801
</div>
667-
</Dialog>
802+
</div>
668803
)
669804
}

0 commit comments

Comments
 (0)