@@ -7,20 +7,167 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
77import { List , type ListRef } from "@opencode-ai/ui/list"
88import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
99import { Spinner } from "@opencode-ai/ui/spinner"
10+ import { Tag } from "@opencode-ai/ui/tag"
1011import { TextField } from "@opencode-ai/ui/text-field"
1112import { 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"
1325import { createStore , produce } from "solid-js/store"
1426import { Link } from "@/components/link"
1527import { useServerSDK } from "@/context/server-sdk"
1628import { useServerSync } from "@/context/server-sync"
1729import { 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