diff options
Diffstat (limited to 'packages/desktop/src/pages')
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 480 |
1 files changed, 8 insertions, 472 deletions
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index ef51361ba..63ee5b2aa 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -1,16 +1,4 @@ -import { - createEffect, - createMemo, - createSignal, - For, - Match, - onCleanup, - onMount, - ParentProps, - Show, - Switch, - type JSX, -} from "solid-js" +import { createEffect, createMemo, createSignal, For, Match, ParentProps, Show, Switch, type JSX } from "solid-js" import { DateTime } from "luxon" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, getAvatarColors } from "@/context/layout" @@ -20,14 +8,13 @@ import { Avatar } from "@opencode-ai/ui/avatar" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { getFilename } from "@opencode-ai/util/path" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Session, Project, ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" +import { Session, Project } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { createStore, produce } from "solid-js/store" import { @@ -40,21 +27,14 @@ import { useDragDropContext, } from "@thisbeyond/solid-dnd" import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" -import { SelectDialog } from "@opencode-ai/ui/select-dialog" -import { Tag } from "@opencode-ai/ui/tag" -import { IconName } from "@opencode-ai/ui/icons/provider" -import { popularProviders, useProviders } from "@/hooks/use-providers" -import { Dialog } from "@opencode-ai/ui/dialog" -import { iife } from "@opencode-ai/util/iife" -import { Link } from "@/components/link" -import { List, ListRef } from "@opencode-ai/ui/list" -import { TextField } from "@opencode-ai/ui/text-field" -import { showToast, Toast } from "@opencode-ai/ui/toast" +import { useProviders } from "@/hooks/use-providers" +import { Toast } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" -import { Spinner } from "@opencode-ai/ui/spinner" import { useNotification } from "@/context/notification" import { Binary } from "@opencode-ai/util/binary" import { Header } from "@/components/header" +import { DialogProvider } from "@/components/dialog-provider" +import { DialogConnect } from "@/components/dialog-connect" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -576,454 +556,10 @@ export default function Layout(props: ParentProps) { </div> <main class="size-full overflow-x-hidden flex flex-col items-start">{props.children}</main> <Show when={layout.dialog.opened() === "provider"}> - <SelectDialog - defaultOpen - title="Connect provider" - placeholder="Search providers" - activeIcon="plus-small" - key={(x) => x?.id} - items={providers.all} - filterKeys={["id", "name"]} - groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - sortGroupsBy={(a, b) => { - if (a.category === "Popular" && b.category !== "Popular") return -1 - if (b.category === "Popular" && a.category !== "Popular") return 1 - return 0 - }} - onSelect={(x) => { - if (!x) return - layout.dialog.connect(x.id) - }} - onOpenChange={(open) => { - if (open) { - layout.dialog.open("provider") - } else { - layout.dialog.close("provider") - } - }} - > - {(i) => ( - <div class="px-1.25 w-full flex items-center gap-x-4"> - <ProviderIcon - data-slot="list-item-extra-icon" - id={i.id as IconName} - // TODO: clean this up after we update icon in models.dev - classList={{ - "text-icon-weak-base": true, - "size-4 mx-0.5": i.id === "opencode", - "size-5": i.id !== "opencode", - }} - /> - <span>{i.name}</span> - <Show when={i.id === "opencode"}> - <Tag>Recommended</Tag> - </Show> - <Show when={i.id === "anthropic"}> - <div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div> - </Show> - </div> - )} - </SelectDialog> + <DialogProvider /> </Show> <Show when={layout.dialog.opened() === "connect"}> - {iife(() => { - const providerID = createMemo(() => layout.connect.provider()!) - const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === providerID())!) - const methods = createMemo( - () => - globalSync.data.provider_auth[providerID()] ?? [ - { - type: "api", - label: "API key", - }, - ], - ) - const [store, setStore] = createStore({ - method: undefined as undefined | ProviderAuthMethod, - authorization: undefined as undefined | ProviderAuthAuthorization, - state: "pending" as undefined | "pending" | "complete" | "error", - error: undefined as string | undefined, - }) - - const methodIndex = createMemo(() => methods().findIndex((x) => x.label === store.method?.label)) - - async function selectMethod(index: number) { - const method = methods()[index] - setStore( - produce((draft) => { - draft.method = method - draft.authorization = undefined - draft.state = undefined - draft.error = undefined - }), - ) - - if (method.type === "oauth") { - setStore("state", "pending") - const start = Date.now() - await globalSDK.client.provider.oauth - .authorize( - { - providerID: providerID(), - method: index, - }, - { throwOnError: true }, - ) - .then((x) => { - const elapsed = Date.now() - start - const delay = 1000 - elapsed - - if (delay > 0) { - setTimeout(() => { - setStore("state", "complete") - setStore("authorization", x.data!) - }, delay) - return - } - setStore("state", "complete") - setStore("authorization", x.data!) - }) - .catch((e) => { - setStore("state", "error") - setStore("error", String(e)) - }) - } - } - - let listRef: ListRef | undefined - function handleKey(e: KeyboardEvent) { - if (e.key === "Enter" && e.target instanceof HTMLInputElement) { - return - } - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - onMount(() => { - if (methods().length === 1) { - selectMethod(0) - } - - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) - }) - }) - - async function complete() { - await globalSDK.client.global.dispose() - setTimeout(() => { - showToast({ - variant: "success", - icon: "circle-check", - title: `${provider().name} connected`, - description: `${provider().name} models are now available to use.`, - }) - layout.connect.complete() - }, 500) - } - - return ( - <Dialog - modal - defaultOpen - onOpenChange={(open) => { - if (open) { - layout.dialog.open("connect") - } else { - layout.dialog.close("connect") - } - }} - > - <Dialog.Header class="px-4.5"> - <Dialog.Title class="flex items-center"> - <IconButton - tabIndex={-1} - icon="arrow-left" - variant="ghost" - onClick={() => { - if (methods().length === 1) { - layout.dialog.open("provider") - return - } - if (store.authorization) { - setStore("authorization", undefined) - setStore("method", undefined) - return - } - if (store.method) { - setStore("method", undefined) - return - } - layout.dialog.open("provider") - }} - /> - </Dialog.Title> - <Dialog.CloseButton tabIndex={-1} /> - </Dialog.Header> - <Dialog.Body> - <div class="flex flex-col gap-6 px-2.5 pb-3"> - <div class="px-2.5 flex gap-4 items-center"> - <ProviderIcon id={providerID() as IconName} class="size-5 shrink-0 icon-strong-base" /> - <div class="text-16-medium text-text-strong"> - <Switch> - <Match - when={providerID() === "anthropic" && store.method?.label?.toLowerCase().includes("max")} - > - Login with Claude Pro/Max - </Match> - <Match when={true}>Connect {provider().name}</Match> - </Switch> - </div> - </div> - <div class="px-2.5 pb-10 flex flex-col gap-6"> - <Switch> - <Match when={store.method === undefined}> - <div class="text-14-regular text-text-base">Select login method for {provider().name}.</div> - <div class=""> - <List - ref={(ref) => (listRef = ref)} - items={methods} - key={(m) => m?.label} - onSelect={async (method, index) => { - if (!method) return - selectMethod(index) - }} - > - {(i) => ( - <div class="w-full flex items-center gap-x-4"> - <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center"> - <div - class="w-2.5 h-0.5 bg-icon-strong-base hidden" - data-slot="list-item-extra-icon" - /> - </div> - <span>{i.label}</span> - </div> - )} - </List> - </div> - </Match> - <Match when={store.state === "pending"}> - <div class="text-14-regular text-text-base"> - <div class="flex items-center gap-x-4"> - <Spinner /> - <span>Authorization in progress...</span> - </div> - </div> - </Match> - <Match when={store.state === "error"}> - <div class="text-14-regular text-text-base"> - <div class="flex items-center gap-x-4"> - <Icon name="circle-ban-sign" class="text-icon-critical-base" /> - <span>Authorization failed: {store.error}</span> - </div> - </div> - </Match> - <Match when={store.method?.type === "api"}> - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const apiKey = formData.get("apiKey") as string - - if (!apiKey?.trim()) { - setFormStore("error", "API key is required") - return - } - - setFormStore("error", undefined) - await globalSDK.client.auth.set({ - providerID: providerID(), - auth: { - type: "api", - key: apiKey, - }, - }) - await complete() - } - - return ( - <div class="flex flex-col gap-6"> - <Switch> - <Match when={provider().id === "opencode"}> - <div class="flex flex-col gap-4"> - <div class="text-14-regular text-text-base"> - OpenCode Zen gives you access to a curated set of reliable optimized models for - coding agents. - </div> - <div class="text-14-regular text-text-base"> - With a single API key you’ll get access to models such as Claude, GPT, Gemini, - GLM and more. - </div> - <div class="text-14-regular text-text-base"> - Visit{" "} - <Link href="https://opencode.ai/zen" tabIndex={-1}> - opencode.ai/zen - </Link>{" "} - to collect your API key. - </div> - </div> - </Match> - <Match when={true}> - <div class="text-14-regular text-text-base"> - Enter your {provider().name} API key to connect your account and use{" "} - {provider().name} models in OpenCode. - </div> - </Match> - </Switch> - <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> - <TextField - autofocus - type="text" - label={`${provider().name} API key`} - placeholder="API key" - name="apiKey" - value={formStore.value} - onChange={setFormStore.bind(null, "value")} - validationState={formStore.error ? "invalid" : undefined} - error={formStore.error} - /> - <Button class="w-auto" type="submit" size="large" variant="primary"> - Submit - </Button> - </form> - </div> - ) - })} - </Match> - <Match when={store.method?.type === "oauth"}> - <Switch> - <Match when={store.authorization?.method === "code"}> - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - onMount(() => { - if (store.authorization?.method === "code" && store.authorization?.url) { - platform.openLink(store.authorization.url) - } - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const code = formData.get("code") as string - - if (!code?.trim()) { - setFormStore("error", "Authorization code is required") - return - } - - setFormStore("error", undefined) - const { error } = await globalSDK.client.provider.oauth.callback({ - providerID: providerID(), - method: methodIndex(), - code, - }) - if (!error) { - await complete() - return - } - setFormStore("error", "Invalid authorization code") - } - - return ( - <div class="flex flex-col gap-6"> - <div class="text-14-regular text-text-base"> - Visit <Link href={store.authorization!.url}>this link</Link> to collect your - authorization code to connect your account and use {provider().name} models in - OpenCode. - </div> - <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> - <TextField - autofocus - type="text" - label={`${store.method?.label} authorization code`} - placeholder="Authorization code" - name="code" - value={formStore.value} - onChange={setFormStore.bind(null, "value")} - validationState={formStore.error ? "invalid" : undefined} - error={formStore.error} - /> - <Button class="w-auto" type="submit" size="large" variant="primary"> - Submit - </Button> - </form> - </div> - ) - })} - </Match> - <Match when={store.authorization?.method === "auto"}> - {iife(() => { - const code = createMemo(() => { - const instructions = store.authorization?.instructions - if (instructions?.includes(":")) { - return instructions?.split(":")[1]?.trim() - } - return instructions - }) - - onMount(async () => { - const result = await globalSDK.client.provider.oauth.callback({ - providerID: providerID(), - method: methodIndex(), - }) - if (result.error) { - // TODO: show error - layout.dialog.close("connect") - return - } - await complete() - }) - - return ( - <div class="flex flex-col gap-6"> - <div class="text-14-regular text-text-base"> - Visit <Link href={store.authorization!.url}>this link</Link> and enter the code - below to connect your account and use {provider().name} models in OpenCode. - </div> - <TextField - label="Confirmation code" - class="font-mono" - value={code()} - readOnly - copyable - /> - <div class="text-14-regular text-text-base flex items-center gap-4"> - <Spinner /> - <span>Waiting for authorization...</span> - </div> - </div> - ) - })} - </Match> - </Switch> - </Match> - </Switch> - </div> - </div> - </Dialog.Body> - </Dialog> - ) - })} + <DialogConnect /> </Show> </div> <Toast.Region /> |
