Kaynağa Gözat

refactor(app): use dropdown for project selector (#33984)

opencode-agent[bot] 2 ay önce
ebeveyn
işleme
19e510f5d2

+ 279 - 67
packages/app/src/components/prompt-project-selector.tsx

@@ -1,7 +1,8 @@
-import { Popover } from "@kobalte/core/popover"
 import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
 import { createStore } from "solid-js/store"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Icon } from "@opencode-ai/ui/icon"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
 import { getProjectAvatarVariant } from "@/context/layout"
 import { useLanguage } from "@/context/language"
@@ -25,12 +26,23 @@ export type PromptProjectControls = {
   add: (title: string, server?: string) => void
 }
 
+const actionPrefix = "action:"
+const projectPrefix = "project:"
+
+function projectKey(project: PromptProject) {
+  return `${projectPrefix}${encodeURIComponent(project.server?.key ?? "")}:${encodeURIComponent(project.worktree)}`
+}
+
+function actionKey(server?: string) {
+  return `${actionPrefix}${encodeURIComponent(server ?? "")}`
+}
+
 export function createPromptProjectController(input: {
   controls: Accessor<PromptProjectControls>
   onDone: () => void
 }) {
   const language = useLanguage()
-  const [store, setStore] = createStore({ open: false, search: "" })
+  const [store, setStore] = createStore({ open: false, search: "", active: "" })
   let searchRef: HTMLInputElement | undefined
 
   const selected = () => {
@@ -53,8 +65,25 @@ export function createPromptProjectController(input: {
       .controls()
       .available.map((project) => project.server)
       .filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index)
+  const keys = () => {
+    if (servers().length <= 1) {
+      return [...projects().map(projectKey), actionKey(servers()[0]?.key)]
+    }
+    return servers().flatMap((server) => [
+      ...projects()
+        .filter((project) => project.server?.key === server!.key)
+        .map(projectKey),
+      actionKey(server!.key),
+    ])
+  }
+  const initialActive = () => {
+    const selectedKey = selected() ? projectKey(selected()!) : undefined
+    const options = keys()
+    if (selectedKey && options.includes(selectedKey)) return selectedKey
+    return options[0] ?? ""
+  }
   const close = () => {
-    setStore({ open: false, search: "" })
+    setStore({ open: false, search: "", active: "" })
     input.onDone()
   }
   const select = (project: PromptProject) => {
@@ -67,7 +96,7 @@ export function createPromptProjectController(input: {
     close()
   }
   const add = (server?: string) => {
-    setStore("open", false)
+    setStore({ open: false, search: "", active: "" })
     input.controls().add(language.t("command.project.open"), server)
   }
 
@@ -75,8 +104,11 @@ export function createPromptProjectController(input: {
     selected,
     projects,
     servers,
+    projectKey,
+    actionKey,
     open: () => store.open,
     search: () => store.search,
+    active: () => store.active,
     labels: {
       add: () => language.t("session.new.project.add"),
       clear: () => language.t("common.clear"),
@@ -86,50 +118,188 @@ export function createPromptProjectController(input: {
     add,
     select,
     setOpen(open: boolean) {
-      setStore("open", open)
-      if (open) requestAnimationFrame(() => searchRef?.focus())
+      if (open) {
+        setStore({ open: true, active: initialActive() })
+        setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
+        return
+      }
+      setStore({ open: false, search: "", active: "" })
     },
     setSearch(value: string) {
-      setStore("search", value)
+      const search = value.trim().toLowerCase()
+      const first = input
+        .controls()
+        .available.find((project) => !search || displayName(project).toLowerCase().includes(search))
+      setStore({ search: value, active: first ? projectKey(first) : actionKey(servers()[0]?.key) })
+    },
+    clearSearch() {
+      setStore({ search: "", active: initialActive() })
+      setTimeout(() => searchRef?.focus())
+    },
+    setActive(key: string) {
+      setStore("active", key)
+    },
+    moveActive(delta: number) {
+      const options = keys()
+      if (options.length === 0) return
+      const index = options.indexOf(store.active)
+      const start = index === -1 ? 0 : index
+      setStore("active", options[(start + delta + options.length) % options.length])
+    },
+    activeProject() {
+      return store.active.startsWith(projectPrefix)
+        ? projects().find((project) => projectKey(project) === store.active)
+        : undefined
+    },
+    activeServer() {
+      return store.active.startsWith(actionPrefix)
+        ? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined
+        : undefined
     },
     setSearchRef(el: HTMLInputElement) {
       searchRef = el
     },
+    focusSearch() {
+      setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
+    },
   }
 }
 
 export type PromptProjectController = ReturnType<typeof createPromptProjectController>
 
 export function PromptProjectSelector(props: { controller: PromptProjectController }) {
+  let contentRef: HTMLDivElement | undefined
+  let restoreTrigger = true
+
+  const activeItem = () =>
+    props.controller.active()
+      ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
+      : undefined
+  const afterClose = (callback: () => void) => {
+    const complete = () => {
+      if (contentRef?.isConnected) {
+        requestAnimationFrame(complete)
+        return
+      }
+      requestAnimationFrame(() => requestAnimationFrame(callback))
+    }
+    requestAnimationFrame(complete)
+  }
+  const selectProject = (project: PromptProject) => {
+    restoreTrigger = false
+    props.controller.setOpen(false)
+    afterClose(() => props.controller.select(project))
+  }
+  const selectAction = (server?: string) => {
+    restoreTrigger = false
+    props.controller.setOpen(false)
+    afterClose(() => props.controller.add(server))
+  }
+  const selectActive = () => {
+    const project = props.controller.activeProject()
+    if (project) {
+      selectProject(project)
+      return
+    }
+    selectAction(props.controller.activeServer())
+  }
+  const moveActive = (delta: number) => {
+    props.controller.moveActive(delta)
+    queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
+  }
+  const focusPreviousControl = () => {
+    const target = Array.from(
+      document.querySelectorAll<HTMLElement>(
+        'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
+      ),
+    )
+      .filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
+      .findLast((element) => element.offsetParent !== null)
+    restoreTrigger = false
+    target?.focus()
+    queueMicrotask(() => {
+      if (props.controller.open()) props.controller.setOpen(false)
+    })
+  }
+  const selectedValue = () => {
+    const project = props.controller.selected()
+    return project ? props.controller.projectKey(project) : undefined
+  }
+
   return (
-    <Popover
+    <DropdownMenu
       open={props.controller.open()}
       placement="bottom-start"
       gutter={4}
+      shift={-6}
       modal={false}
       onOpenChange={(open) => props.controller.setOpen(open)}
     >
-      <Popover.Trigger as={ProjectTrigger} controller={props.controller} />
-      <Popover.Portal>
-        <Popover.Content
-          class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
+      <DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
+      <DropdownMenu.Portal>
+        <DropdownMenu.Content
+          ref={contentRef}
+          id="prompt-project-menu"
+          class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
           onOpenAutoFocus={(event) => event.preventDefault()}
+          onPointerDownOutside={() => (restoreTrigger = false)}
+          onFocusOutside={() => (restoreTrigger = false)}
+          onCloseAutoFocus={(event) => {
+            if (!restoreTrigger) event.preventDefault()
+          }}
         >
           <div class="flex flex-col p-0.5">
-            <div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted">
+            <div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
               <Icon name="magnifying-glass" size="small" class="shrink-0" />
               <input
                 ref={(el) => props.controller.setSearchRef(el)}
                 value={props.controller.search()}
                 placeholder={props.controller.labels.search()}
+                aria-autocomplete="list"
+                aria-controls="prompt-project-menu"
+                aria-activedescendant={props.controller.active() || undefined}
                 class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
                 onInput={(event) => props.controller.setSearch(event.currentTarget.value)}
+                onKeyDown={(event) => {
+                  if (event.key === "Tab") {
+                    event.preventDefault()
+                    event.stopPropagation()
+                    if (event.shiftKey) {
+                      focusPreviousControl()
+                      return
+                    }
+                    activeItem()?.focus()
+                    return
+                  }
+                  event.stopPropagation()
+                  if (event.key === "Escape") {
+                    event.preventDefault()
+                    props.controller.setOpen(false)
+                    return
+                  }
+                  if (event.altKey || event.metaKey) return
+                  if (event.key === "ArrowDown") {
+                    event.preventDefault()
+                    moveActive(1)
+                    return
+                  }
+                  if (event.key === "ArrowUp") {
+                    event.preventDefault()
+                    moveActive(-1)
+                    return
+                  }
+                  if (event.key === "Enter" && !event.isComposing) {
+                    event.preventDefault()
+                    selectActive()
+                  }
+                }}
               />
               <Show when={props.controller.search().trim()}>
                 <button
                   type="button"
-                  class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
-                  onClick={() => props.controller.setSearch("")}
+                  class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
+                  onPointerDown={(event) => event.preventDefault()}
+                  onClick={() => props.controller.clearSearch()}
                   aria-label={props.controller.labels.clear()}
                 >
                   <Icon name="close-small" size="small" />
@@ -139,15 +309,13 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
             <Show
               when={props.controller.servers().length > 1}
               fallback={
-                <For each={props.controller.projects()}>
-                  {(project) => (
-                    <ProjectItem
-                      project={project}
-                      selected={props.controller.selected()}
-                      onSelect={props.controller.select}
-                    />
-                  )}
-                </For>
+                <DropdownMenu.RadioGroup value={selectedValue()}>
+                  <For each={props.controller.projects()}>
+                    {(project) => (
+                      <ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
+                    )}
+                  </For>
+                </DropdownMenu.RadioGroup>
               }
             >
               <For each={props.controller.servers()}>
@@ -156,19 +324,14 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
                     <div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
                       {server!.name}
                     </div>
-                    <For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
-                      {(project) => (
-                        <ProjectItem
-                          project={project}
-                          selected={props.controller.selected()}
-                          onSelect={props.controller.select}
-                        />
-                      )}
-                    </For>
-                    <ProjectAction
-                      label={props.controller.labels.add()}
-                      onSelect={() => props.controller.add(server!.key)}
-                    />
+                    <DropdownMenu.RadioGroup value={selectedValue()}>
+                      <For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
+                        {(project) => (
+                          <ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
+                        )}
+                      </For>
+                    </DropdownMenu.RadioGroup>
+                    <ProjectAction server={server!.key} controller={props.controller} onSelect={selectAction} />
                   </div>
                 )}
               </For>
@@ -178,14 +341,15 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
             <div class="h-px bg-v2-border-border-muted" />
             <div class="flex flex-col p-0.5">
               <ProjectAction
-                label={props.controller.labels.add()}
-                onSelect={() => props.controller.add(props.controller.servers()[0]?.key)}
+                server={props.controller.servers()[0]?.key}
+                controller={props.controller}
+                onSelect={selectAction}
               />
             </div>
           </Show>
-        </Popover.Content>
-      </Popover.Portal>
-    </Popover>
+        </DropdownMenu.Content>
+      </DropdownMenu.Portal>
+    </DropdownMenu>
   )
 }
 
@@ -205,15 +369,28 @@ export function PromptProjectAddButton(props: { controller: PromptProjectControl
 }
 
 function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) {
-  const [local, rest] = splitProps(props, ["controller", "class", "onClick"])
+  const [local, rest] = splitProps(props, ["controller", "class", "classList", "onClick", "onKeyDown"])
   const project = () => local.controller.selected()
   return (
     <button
       {...rest}
       data-action="prompt-project"
       type="button"
-      class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
-      onClick={() => local.controller.setOpen(true)}
+      class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
+      classList={{
+        ...local.classList,
+        "hover:bg-v2-overlay-simple-overlay-hover": !local.controller.open(),
+        "bg-v2-overlay-simple-overlay-pressed": local.controller.open(),
+      }}
+      onClick={local.onClick ?? (() => local.controller.setOpen(true))}
+      onKeyDown={(event) => {
+        if (!local.controller.open() && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
+          event.preventDefault()
+          event.stopPropagation()
+          return
+        }
+        if (typeof local.onKeyDown === "function") local.onKeyDown(event)
+      }}
     >
       <Show
         when={project()}
@@ -237,42 +414,77 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
 
 function ProjectItem(props: {
   project: PromptProject
-  selected?: PromptProject
+  controller: PromptProjectController
   onSelect: (project: PromptProject) => void
 }) {
+  const key = () => props.controller.projectKey(props.project)
   return (
-    <button
-      type="button"
-      class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
-      onClick={() => props.onSelect(props.project)}
+    <DropdownMenu.RadioItem
+      id={key()}
+      value={key()}
+      data-option-key={key()}
+      class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
+      classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
+      style={{
+        "font-family": "var(--v2-font-family-sans)",
+        "font-size": "13px",
+        "font-weight": 440,
+        "line-height": "20px",
+        "letter-spacing": "-0.04px",
+        color: "var(--v2-text-text-base)",
+        padding: "0 12px",
+      }}
+      closeOnSelect
+      onMouseEnter={() => {
+        props.controller.setActive(key())
+        props.controller.focusSearch()
+      }}
+      onSelect={() => props.onSelect(props.project)}
     >
       <ProjectAvatar
         fallback={displayName(props.project)}
         src={getProjectAvatarSource(props.project.id, props.project.icon)}
         variant={getProjectAvatarVariant(props.project.icon?.color)}
       />
-      <span class="min-w-0 flex-1 truncate leading-5">{displayName(props.project)}</span>
-      <Show
-        when={
-          props.selected?.worktree === props.project.worktree &&
-          props.selected?.server?.key === props.project.server?.key
-        }
-      >
-        <Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
-      </Show>
-    </button>
+      <DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">{displayName(props.project)}</DropdownMenu.ItemLabel>
+      <DropdownMenu.ItemIndicator style={{ width: "14px", height: "14px", right: "12px" }}>
+        <IconV2 name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
+      </DropdownMenu.ItemIndicator>
+    </DropdownMenu.RadioItem>
   )
 }
 
-function ProjectAction(props: { label: string; onSelect: () => void }) {
+function ProjectAction(props: {
+  server?: string
+  controller: PromptProjectController
+  onSelect: (server?: string) => void
+}) {
+  const key = () => props.controller.actionKey(props.server)
   return (
-    <button
-      type="button"
-      class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
-      onClick={props.onSelect}
+    <DropdownMenu.Item
+      id={key()}
+      data-option-key={key()}
+      class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
+      classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
+      style={{
+        "font-family": "var(--v2-font-family-sans)",
+        "font-size": "13px",
+        "font-weight": 440,
+        "line-height": "20px",
+        "letter-spacing": "-0.04px",
+        color: "var(--v2-text-text-base)",
+        padding: "0 12px",
+      }}
+      onMouseEnter={() => {
+        props.controller.setActive(key())
+        props.controller.focusSearch()
+      }}
+      onSelect={() => props.onSelect(props.server)}
     >
       <Icon name="plus" size="small" />
-      <span class="min-w-0 flex-1 truncate leading-5">{props.label}</span>
-    </button>
+      <DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">
+        {props.controller.labels.add()}
+      </DropdownMenu.ItemLabel>
+    </DropdownMenu.Item>
   )
 }

+ 4 - 0
packages/ui/src/v2/components/icon.tsx

@@ -49,6 +49,10 @@ const icons = {
     viewBox: "0 0 16 16",
     body: `<path d="M5 6.5L8 9.5L11 6.5" stroke="currentColor"/>`,
   },
+  check: {
+    viewBox: "0 0 16 16",
+    body: `<path d="M3.53613 8.17857L6.39328 11.75L12.4647 4.25" stroke="currentColor"/>`,
+  },
   close: {
     viewBox: "0 0 20 20",
     body: `<path d="M14.4446 5.55566L5.55566 14.4446M5.55566 5.55566L14.4446 14.4446" stroke="currentColor" stroke-linejoin="round"/>`,