Jelajahi Sumber

wip(desktop): progress

Adam 2 bulan lalu
induk
melakukan
2613f44961

+ 35 - 39
packages/desktop/src/components/dialog-connect.tsx

@@ -1,6 +1,6 @@
 import { Component, createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
 import { createStore, produce } from "solid-js/store"
-import { useLayout } from "@/context/layout"
+import { useDialog } from "@/context/dialog"
 import { useGlobalSync } from "@/context/global-sync"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { usePlatform } from "@/context/platform"
@@ -17,18 +17,19 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { IconName } from "@opencode-ai/ui/icons/provider"
 import { iife } from "@opencode-ai/util/iife"
 import { Link } from "@/components/link"
+import { DialogSelectProvider } from "./dialog-select-provider"
+import { DialogModel } from "./dialog-model"
 
-export const DialogConnect: Component = () => {
-  const layout = useLayout()
+export const DialogConnect: Component<{ provider: string }> = (props) => {
+  const dialog = useDialog()
   const globalSync = useGlobalSync()
   const globalSDK = useGlobalSDK()
   const platform = usePlatform()
 
-  const providerID = createMemo(() => layout.connect.provider()!)
-  const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === providerID())!)
+  const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
   const methods = createMemo(
     () =>
-      globalSync.data.provider_auth[providerID()] ?? [
+      globalSync.data.provider_auth[props.provider] ?? [
         {
           type: "api",
           label: "API key",
@@ -61,7 +62,7 @@ export const DialogConnect: Component = () => {
       await globalSDK.client.provider.oauth
         .authorize(
           {
-            providerID: providerID(),
+            providerID: props.provider,
             method: index,
           },
           { throwOnError: true },
@@ -116,55 +117,50 @@ export const DialogConnect: Component = () => {
         title: `${provider().name} connected`,
         description: `${provider().name} models are now available to use.`,
       })
-      layout.connect.complete()
+      dialog.replace(() => <DialogModel connectedProvider={props.provider} />)
     }, 500)
   }
 
+  function goBack() {
+    if (methods().length === 1) {
+      dialog.replace(() => <DialogSelectProvider />)
+      return
+    }
+    if (store.authorization) {
+      setStore("authorization", undefined)
+      setStore("method", undefined)
+      return
+    }
+    if (store.method) {
+      setStore("method", undefined)
+      return
+    }
+    dialog.replace(() => <DialogSelectProvider />)
+  }
+
   return (
     <Dialog
       modal
       defaultOpen
       onOpenChange={(open) => {
-        if (open) {
-          layout.dialog.open("connect")
-        } else {
-          layout.dialog.close("connect")
+        if (!open) {
+          dialog.clear()
         }
       }}
     >
       <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")
-            }}
-          />
+          <IconButton tabIndex={-1} icon="arrow-left" variant="ghost" onClick={goBack} />
         </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" />
+            <ProviderIcon id={props.provider 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")}>
+                <Match when={props.provider === "anthropic" && store.method?.label?.toLowerCase().includes("max")}>
                   Login with Claude Pro/Max
                 </Match>
                 <Match when={true}>Connect {provider().name}</Match>
@@ -233,7 +229,7 @@ export const DialogConnect: Component = () => {
 
                     setFormStore("error", undefined)
                     await globalSDK.client.auth.set({
-                      providerID: providerID(),
+                      providerID: props.provider,
                       auth: {
                         type: "api",
                         key: apiKey,
@@ -320,7 +316,7 @@ export const DialogConnect: Component = () => {
 
                         setFormStore("error", undefined)
                         const { error } = await globalSDK.client.provider.oauth.callback({
-                          providerID: providerID(),
+                          providerID: props.provider,
                           method: methodIndex(),
                           code,
                         })
@@ -369,12 +365,12 @@ export const DialogConnect: Component = () => {
 
                       onMount(async () => {
                         const result = await globalSDK.client.provider.oauth.callback({
-                          providerID: providerID(),
+                          providerID: props.provider,
                           method: methodIndex(),
                         })
                         if (result.error) {
                           // TODO: show error
-                          layout.dialog.close("connect")
+                          dialog.clear()
                           return
                         }
                         await complete()

+ 14 - 18
packages/desktop/src/components/dialog-model.tsx

@@ -1,6 +1,6 @@
 import { Component, createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js"
 import { useLocal } from "@/context/local"
-import { useLayout } from "@/context/layout"
+import { useDialog } from "@/context/dialog"
 import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { SelectDialog } from "@opencode-ai/ui/select-dialog"
 import { Button } from "@opencode-ai/ui/button"
@@ -10,10 +10,12 @@ import { List, ListRef } from "@opencode-ai/ui/list"
 import { iife } from "@opencode-ai/util/iife"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { IconName } from "@opencode-ai/ui/icons/provider"
+import { DialogSelectProvider } from "./dialog-select-provider"
+import { DialogConnect } from "./dialog-connect"
 
-export const DialogModel: Component = () => {
+export const DialogModel: Component<{ connectedProvider?: string }> = (props) => {
   const local = useLocal()
-  const layout = useLayout()
+  const dialog = useDialog()
   const providers = useProviders()
 
   return (
@@ -24,18 +26,14 @@ export const DialogModel: Component = () => {
             local.model
               .list()
               .filter((m) => m.visible)
-              .filter((m) =>
-                layout.connect.state() === "complete" ? m.provider.id === layout.connect.provider() : true,
-              ),
+              .filter((m) => (props.connectedProvider ? m.provider.id === props.connectedProvider : true)),
           )
           return (
             <SelectDialog
               defaultOpen
               onOpenChange={(open) => {
-                if (open) {
-                  layout.dialog.open("model")
-                } else {
-                  layout.dialog.close("model")
+                if (!open) {
+                  dialog.clear()
                 }
               }}
               title="Select model"
@@ -66,7 +64,7 @@ export const DialogModel: Component = () => {
                   class="h-7 -my-1 text-14-medium"
                   icon="plus-small"
                   tabIndex={-1}
-                  onClick={() => layout.dialog.open("provider")}
+                  onClick={() => dialog.replace(() => <DialogSelectProvider />)}
                 >
                   Connect provider
                 </Button>
@@ -107,10 +105,8 @@ export const DialogModel: Component = () => {
               modal
               defaultOpen
               onOpenChange={(open) => {
-                if (open) {
-                  layout.dialog.open("model")
-                } else {
-                  layout.dialog.close("model")
+                if (!open) {
+                  dialog.clear()
                 }
               }}
             >
@@ -130,7 +126,7 @@ export const DialogModel: Component = () => {
                       local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
                         recent: true,
                       })
-                      layout.dialog.close("model")
+                      dialog.clear()
                     }}
                   >
                     {(i) => (
@@ -163,7 +159,7 @@ export const DialogModel: Component = () => {
                           }}
                           onSelect={(x) => {
                             if (!x) return
-                            layout.dialog.connect(x.id)
+                            dialog.replace(() => <DialogConnect provider={x.id} />)
                           }}
                         >
                           {(i) => (
@@ -193,7 +189,7 @@ export const DialogModel: Component = () => {
                           class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
                           icon="dot-grid"
                           onClick={() => {
-                            layout.dialog.open("provider")
+                            dialog.replace(() => <DialogSelectProvider />)
                           }}
                         >
                           View all providers

+ 7 - 8
packages/desktop/src/components/dialog-provider.tsx → packages/desktop/src/components/dialog-select-provider.tsx

@@ -1,13 +1,14 @@
 import { Component, Show } from "solid-js"
-import { useLayout } from "@/context/layout"
+import { useDialog } from "@/context/dialog"
 import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { SelectDialog } from "@opencode-ai/ui/select-dialog"
 import { Tag } from "@opencode-ai/ui/tag"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { IconName } from "@opencode-ai/ui/icons/provider"
+import { DialogConnect } from "./dialog-connect"
 
-export const DialogProvider: Component = () => {
-  const layout = useLayout()
+export const DialogSelectProvider: Component = () => {
+  const dialog = useDialog()
   const providers = useProviders()
 
   return (
@@ -32,13 +33,11 @@ export const DialogProvider: Component = () => {
       }}
       onSelect={(x) => {
         if (!x) return
-        layout.dialog.connect(x.id)
+        dialog.replace(() => <DialogConnect provider={x.id} />)
       }}
       onOpenChange={(open) => {
-        if (open) {
-          layout.dialog.open("provider")
-        } else {
-          layout.dialog.close("provider")
+        if (!open) {
+          dialog.clear()
         }
       }}
     >

+ 3 - 6
packages/desktop/src/components/prompt-input.tsx

@@ -15,7 +15,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Select } from "@opencode-ai/ui/select"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { useLayout } from "@/context/layout"
+import { useDialog } from "@/context/dialog"
 import { DialogModel } from "@/components/dialog-model"
 
 interface PromptInputProps {
@@ -57,7 +57,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const sync = useSync()
   const local = useLocal()
   const session = useSession()
-  const layout = useLayout()
+  const dialog = useDialog()
   let editorRef!: HTMLDivElement
 
   const [store, setStore] = createStore<{
@@ -610,14 +610,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               class="capitalize"
               variant="ghost"
             />
-            <Button as="div" variant="ghost" onClick={() => layout.dialog.open("model")}>
+            <Button as="div" variant="ghost" onClick={() => dialog.push(() => <DialogModel />)}>
               {local.model.current()?.name ?? "Select model"}
               <span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
               <Icon name="chevron-down" size="small" />
             </Button>
-            <Show when={layout.dialog.opened() === "model"}>
-              <DialogModel />
-            </Show>
           </div>
           <Tooltip
             placement="top"

+ 80 - 0
packages/desktop/src/context/dialog.tsx

@@ -0,0 +1,80 @@
+import { createEffect, For, onCleanup, Show, type JSX } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+
+type DialogElement = JSX.Element | (() => JSX.Element)
+
+export const { use: useDialog, provider: DialogProvider } = createSimpleContext({
+  name: "Dialog",
+  init: () => {
+    const [store, setStore] = createStore({
+      stack: [] as {
+        element: DialogElement
+        onClose?: () => void
+      }[],
+    })
+
+    function handleKeyDown(e: KeyboardEvent) {
+      if (e.key === "Escape" && store.stack.length > 0) {
+        const current = store.stack.at(-1)!
+        current.onClose?.()
+        setStore("stack", store.stack.slice(0, -1))
+        e.preventDefault()
+        e.stopPropagation()
+      }
+    }
+
+    createEffect(() => {
+      document.addEventListener("keydown", handleKeyDown, true)
+      onCleanup(() => {
+        document.removeEventListener("keydown", handleKeyDown, true)
+      })
+    })
+
+    return {
+      get stack() {
+        return store.stack
+      },
+      push(element: DialogElement, onClose?: () => void) {
+        setStore("stack", (s) => [...s, { element, onClose }])
+      },
+      pop() {
+        const current = store.stack.at(-1)
+        current?.onClose?.()
+        setStore("stack", store.stack.slice(0, -1))
+      },
+      replace(element: DialogElement, onClose?: () => void) {
+        for (const item of store.stack) {
+          item.onClose?.()
+        }
+        setStore("stack", [{ element, onClose }])
+      },
+      clear() {
+        for (const item of store.stack) {
+          item.onClose?.()
+        }
+        setStore("stack", [])
+      },
+    }
+  },
+})
+
+export function DialogRoot(props: { children?: JSX.Element }) {
+  const dialog = useDialog()
+  return (
+    <>
+      {props.children}
+      <Show when={dialog.stack.length > 0}>
+        <div data-component="dialog-stack">
+          <For each={dialog.stack}>
+            {(item, index) => (
+              <Show when={index() === dialog.stack.length - 1}>
+                {typeof item.element === "function" ? item.element() : item.element}
+              </Show>
+            )}
+          </For>
+        </div>
+      </Show>
+    </>
+  )
+}

+ 4 - 65
packages/desktop/src/context/layout.tsx

@@ -1,5 +1,5 @@
-import { createStore, produce } from "solid-js/store"
-import { batch, createMemo, onMount } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createMemo, onMount } from "solid-js"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { makePersisted } from "@solid-primitives/storage"
 import { useGlobalSync } from "./global-sync"
@@ -22,8 +22,6 @@ export function getAvatarColors(key?: string) {
   }
 }
 
-type Dialog = "provider" | "model" | "connect" | "manage-models"
-
 export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
   name: "Layout",
   init: () => {
@@ -45,22 +43,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         },
       }),
       {
-        name: "layout.v1",
+        name: "layout.v2",
       },
     )
-    const [ephemeral, setEphemeral] = createStore<{
-      connect: {
-        provider?: string
-        state?: "pending" | "complete" | "error"
-        error?: string
-      }
-      dialog: {
-        open?: Dialog
-      }
-    }>({
-      connect: {},
-      dialog: {},
-    })
+
     const usedColors = new Set<AvatarColorKey>()
 
     function pickAvailableColor(): AvatarColorKey {
@@ -169,53 +155,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           setStore("review", "state", "tab")
         },
       },
-      dialog: {
-        opened: createMemo(() => ephemeral.dialog?.open),
-        open(dialog: Dialog) {
-          setEphemeral("dialog", "open", dialog)
-        },
-        close(dialog: Dialog) {
-          if (ephemeral.dialog.open === dialog) {
-            setEphemeral(
-              produce((state) => {
-                state.dialog.open = undefined
-                state.connect = {}
-              }),
-            )
-          }
-        },
-        connect(provider: string) {
-          setEphemeral(
-            produce((state) => {
-              state.dialog.open = "connect"
-              state.connect = { provider, state: "pending" }
-            }),
-          )
-        },
-      },
-      connect: {
-        provider: createMemo(() => ephemeral.connect.provider),
-        state: createMemo(() => ephemeral.connect.state),
-        complete() {
-          setEphemeral(
-            produce((state) => {
-              state.dialog.open = "model"
-              state.connect.state = "complete"
-            }),
-          )
-        },
-        error(message: string) {
-          setEphemeral(
-            produce((state) => {
-              state.connect.state = "error"
-              state.connect.error = message
-            }),
-          )
-        },
-        clear() {
-          setEphemeral("connect", {})
-        },
-      },
     }
   },
 })

+ 6 - 1
packages/desktop/src/pages/directory-layout.tsx

@@ -6,6 +6,7 @@ import { LocalProvider } from "@/context/local"
 import { base64Decode } from "@opencode-ai/util/encode"
 import { DataProvider } from "@opencode-ai/ui/context"
 import { iife } from "@opencode-ai/util/iife"
+import { DialogProvider, DialogRoot } from "@/context/dialog"
 
 export default function Layout(props: ParentProps) {
   const params = useParams()
@@ -20,7 +21,11 @@ export default function Layout(props: ParentProps) {
             const sync = useSync()
             return (
               <DataProvider data={sync.data} directory={directory()}>
-                <LocalProvider>{props.children}</LocalProvider>
+                <LocalProvider>
+                  <DialogProvider>
+                    <DialogRoot>{props.children}</DialogRoot>
+                  </DialogProvider>
+                </LocalProvider>
               </DataProvider>
             )
           })}

+ 2 - 14
packages/desktop/src/pages/layout.tsx

@@ -33,8 +33,6 @@ import { useGlobalSDK } from "@/context/global-sdk"
 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({
@@ -90,10 +88,6 @@ export default function Layout(props: ParentProps) {
     }
   }
 
-  async function connectProvider() {
-    layout.dialog.open("provider")
-  }
-
   createEffect(() => {
     if (!params.dir || !params.id) return
     const directory = base64Decode(params.dir)
@@ -494,7 +488,7 @@ export default function Layout(props: ParentProps) {
                       class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
                       size="large"
                       icon="plus"
-                      onClick={connectProvider}
+                      // onClick={connectProvider}
                     >
                       <Show when={layout.sidebar.opened()}>Connect provider</Show>
                     </Button>
@@ -508,7 +502,7 @@ export default function Layout(props: ParentProps) {
                     variant="ghost"
                     size="large"
                     icon="plus"
-                    onClick={connectProvider}
+                    // onClick={connectProvider}
                   >
                     <Show when={layout.sidebar.opened()}>Connect provider</Show>
                   </Button>
@@ -555,12 +549,6 @@ export default function Layout(props: ParentProps) {
           </div>
         </div>
         <main class="size-full overflow-x-hidden flex flex-col items-start">{props.children}</main>
-        <Show when={layout.dialog.opened() === "provider"}>
-          <DialogProvider />
-        </Show>
-        <Show when={layout.dialog.opened() === "connect"}>
-          <DialogConnect />
-        </Show>
       </div>
       <Toast.Region />
     </div>