Просмотр исходного кода

fix(app): move session search to command palette

Adam 3 недель назад
Родитель
Сommit
e709808b32
2 измененных файлов с 170 добавлено и 228 удалено
  1. 168 18
      packages/app/src/components/dialog-select-file.tsx
  2. 2 210
      packages/app/src/pages/layout.tsx

+ 168 - 18
packages/app/src/components/dialog-select-file.tsx

@@ -1,17 +1,22 @@
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { Icon } from "@opencode-ai/ui/icon"
 import { Keybind } from "@opencode-ai/ui/keybind"
 import { List } from "@opencode-ai/ui/list"
+import { base64Encode } from "@opencode-ai/util/encode"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { useParams } from "@solidjs/router"
-import { createMemo, createSignal, onCleanup, Show } from "solid-js"
+import { useNavigate, useParams } from "@solidjs/router"
+import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
 import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useGlobalSync } from "@/context/global-sync"
 import { useLayout } from "@/context/layout"
 import { useFile } from "@/context/file"
 import { useLanguage } from "@/context/language"
+import { decode64 } from "@/utils/base64"
 
-type EntryType = "command" | "file"
+type EntryType = "command" | "file" | "session"
 
 type Entry = {
   id: string
@@ -22,6 +27,9 @@ type Entry = {
   category: string
   option?: CommandOption
   path?: string
+  directory?: string
+  sessionID?: string
+  archived?: number
 }
 
 type DialogSelectFileMode = "all" | "files"
@@ -33,6 +41,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
   const file = useFile()
   const dialog = useDialog()
   const params = useParams()
+  const navigate = useNavigate()
+  const globalSDK = useGlobalSDK()
+  const globalSync = useGlobalSync()
   const filesOnly = () => props.mode === "files"
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const tabs = createMemo(() => layout.tabs(sessionKey))
@@ -73,6 +84,52 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
     path,
   })
 
+  const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
+  const project = createMemo(() => {
+    const directory = projectDirectory()
+    if (!directory) return
+    return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
+  })
+  const workspaces = createMemo(() => {
+    const directory = projectDirectory()
+    const current = project()
+    if (!current) return directory ? [directory] : []
+
+    const dirs = [current.worktree, ...(current.sandboxes ?? [])]
+    if (directory && !dirs.includes(directory)) return [...dirs, directory]
+    return dirs
+  })
+  const homedir = createMemo(() => globalSync.data.path.home)
+  const label = (directory: string) => {
+    const current = project()
+    const kind =
+      current && directory === current.worktree
+        ? language.t("workspace.type.local")
+        : language.t("workspace.type.sandbox")
+    const [store] = globalSync.child(directory, { bootstrap: false })
+    const home = homedir()
+    const path = home ? directory.replace(home, "~") : directory
+    const name = store.vcs?.branch ?? getFilename(directory)
+    return `${kind} : ${name || path}`
+  }
+
+  const sessionItem = (input: {
+    directory: string
+    id: string
+    title: string
+    description: string
+    archived?: number
+  }): Entry => ({
+    id: `session:${input.directory}:${input.id}`,
+    type: "session",
+    title: input.title,
+    description: input.description,
+    category: language.t("command.category.session"),
+    directory: input.directory,
+    sessionID: input.id,
+    archived: input.archived,
+  })
+
   const list = createMemo(() => allowed().map(commandItem))
 
   const picks = createMemo(() => {
@@ -122,6 +179,68 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
     return out
   }
 
+  const sessionToken = { value: 0 }
+  let sessionInflight: Promise<Entry[]> | undefined
+  let sessionAll: Entry[] | undefined
+
+  const sessions = (text: string) => {
+    const query = text.trim()
+    if (!query) {
+      sessionToken.value += 1
+      sessionInflight = undefined
+      sessionAll = undefined
+      return [] as Entry[]
+    }
+
+    if (sessionAll) return sessionAll
+    if (sessionInflight) return sessionInflight
+
+    const current = sessionToken.value
+    const dirs = workspaces()
+    if (dirs.length === 0) return [] as Entry[]
+
+    sessionInflight = Promise.all(
+      dirs.map((directory) => {
+        const description = label(directory)
+        return globalSDK.client.session
+          .list({ directory, roots: true })
+          .then((x) =>
+            (x.data ?? [])
+              .filter((s) => !!s?.id)
+              .map((s) => ({
+                id: s.id,
+                title: s.title ?? language.t("command.session.new"),
+                description,
+                directory,
+                archived: s.time?.archived,
+              })),
+          )
+          .catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
+      }),
+    )
+      .then((results) => {
+        if (sessionToken.value !== current) return [] as Entry[]
+        const seen = new Set<string>()
+        const next = results
+          .flat()
+          .filter((item) => {
+            const key = `${item.directory}:${item.id}`
+            if (seen.has(key)) return false
+            seen.add(key)
+            return true
+          })
+          .map(sessionItem)
+        sessionAll = next
+        return next
+      })
+      .catch(() => [] as Entry[])
+      .finally(() => {
+        sessionInflight = undefined
+      })
+
+    return sessionInflight
+  }
+
   const items = async (text: string) => {
     const query = text.trim()
     setGrouped(query.length > 0)
@@ -146,9 +265,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
       const files = await file.searchFiles(query)
       return files.map(fileItem)
     }
-    const files = await file.searchFiles(query)
+
+    const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
     const entries = files.map(fileItem)
-    return [...list(), ...entries]
+    return [...list(), ...nextSessions, ...entries]
   }
 
   const handleMove = (item: Entry | undefined) => {
@@ -178,6 +298,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
       return
     }
 
+    if (item.type === "session") {
+      if (!item.directory || !item.sessionID) return
+      navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
+      return
+    }
+
     if (!item.path) return
     open(item.path)
   }
@@ -202,13 +328,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
         items={items}
         key={(item) => item.id}
         filterKeys={["title", "description", "category"]}
-        groupBy={(item) => item.category}
+        groupBy={grouped() ? (item) => item.category : () => ""}
         onMove={handleMove}
         onSelect={handleSelect}
       >
         {(item) => (
-          <Show
-            when={item.type === "command"}
+          <Switch
             fallback={
               <div class="w-full flex items-center justify-between rounded-md pl-1">
                 <div class="flex items-center gap-x-3 grow min-w-0">
@@ -223,18 +348,43 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
               </div>
             }
           >
-            <div class="w-full flex items-center justify-between gap-4">
-              <div class="flex items-center gap-2 min-w-0">
-                <span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
-                <Show when={item.description}>
-                  <span class="text-14-regular text-text-weak truncate">{item.description}</span>
+            <Match when={item.type === "command"}>
+              <div class="w-full flex items-center justify-between gap-4">
+                <div class="flex items-center gap-2 min-w-0">
+                  <span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
+                  <Show when={item.description}>
+                    <span class="text-14-regular text-text-weak truncate">{item.description}</span>
+                  </Show>
+                </div>
+                <Show when={item.keybind}>
+                  <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
                 </Show>
               </div>
-              <Show when={item.keybind}>
-                <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
-              </Show>
-            </div>
-          </Show>
+            </Match>
+            <Match when={item.type === "session"}>
+              <div class="w-full flex items-center justify-between rounded-md pl-1">
+                <div class="flex items-center gap-x-3 grow min-w-0">
+                  <Icon name="bubble-5" size="small" class="shrink-0 text-icon-weak" />
+                  <div class="flex items-center gap-2 min-w-0">
+                    <span
+                      class="text-14-regular text-text-strong truncate"
+                      classList={{ "opacity-70": !!item.archived }}
+                    >
+                      {item.title}
+                    </span>
+                    <Show when={item.description}>
+                      <span
+                        class="text-14-regular text-text-weak truncate"
+                        classList={{ "opacity-70": !!item.archived }}
+                      >
+                        {item.description}
+                      </span>
+                    </Show>
+                  </div>
+                </div>
+              </div>
+            </Match>
+          </Switch>
         )}
       </List>
     </Dialog>

+ 2 - 210
packages/app/src/pages/layout.tsx

@@ -27,7 +27,6 @@ import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { InlineInput } from "@opencode-ai/ui/inline-input"
-import { List, type ListRef } from "@opencode-ai/ui/list"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { HoverCard } from "@opencode-ai/ui/hover-card"
 import { MessageNav } from "@opencode-ai/ui/message-nav"
@@ -2706,14 +2705,6 @@ export default function Layout(props: ParentProps) {
   }
 
   const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
-    type SearchItem = {
-      id: string
-      title: string
-      directory: string
-      label: string
-      archived?: number
-    }
-
     const projectName = createMemo(() => {
       const project = panelProps.project
       if (!project) return ""
@@ -2729,107 +2720,6 @@ export default function Layout(props: ParentProps) {
     })
     const homedir = createMemo(() => globalSync.data.path.home)
 
-    const [search, setSearch] = createStore({
-      value: "",
-    })
-    const searching = createMemo(() => search.value.trim().length > 0)
-    let searchRef: HTMLInputElement | undefined
-    let listRef: ListRef | undefined
-
-    const token = { value: 0 }
-    let inflight: Promise<SearchItem[]> | undefined
-    let all: SearchItem[] | undefined
-
-    const reset = () => {
-      token.value += 1
-      inflight = undefined
-      all = undefined
-      setSearch({ value: "" })
-      listRef = undefined
-    }
-
-    const open = (item: SearchItem | undefined) => {
-      if (!item) return
-
-      const href = `/${base64Encode(item.directory)}/session/${item.id}`
-      if (!layout.sidebar.opened()) {
-        setState("hoverSession", undefined)
-        setState("hoverProject", undefined)
-      }
-      reset()
-      navigate(href)
-      layout.mobileSidebar.hide()
-    }
-
-    const items = (filter: string) => {
-      const query = filter.trim()
-      if (!query) {
-        token.value += 1
-        inflight = undefined
-        all = undefined
-        return [] as SearchItem[]
-      }
-
-      const project = panelProps.project
-      if (!project) return [] as SearchItem[]
-      if (all) return all
-      if (inflight) return inflight
-
-      const current = token.value
-      const dirs = workspaceIds(project)
-      inflight = Promise.all(
-        dirs.map((input) => {
-          const directory = workspaceKey(input)
-          const [workspaceStore] = globalSync.child(directory, { bootstrap: false })
-          const kind =
-            directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
-          const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
-          const label = `${kind} : ${name}`
-          return globalSDK.client.session
-            .list({ directory, roots: true })
-            .then((x) =>
-              (x.data ?? [])
-                .filter((s) => !!s?.id)
-                .map((s) => ({
-                  id: s.id,
-                  title: s.title ?? language.t("command.session.new"),
-                  directory,
-                  label,
-                  archived: s.time?.archived,
-                })),
-            )
-            .catch(() => [] as SearchItem[])
-        }),
-      )
-        .then((results) => {
-          if (token.value !== current) return [] as SearchItem[]
-
-          const seen = new Set<string>()
-          const next = results.flat().filter((item) => {
-            const key = `${item.directory}:${item.id}`
-            if (seen.has(key)) return false
-            seen.add(key)
-            return true
-          })
-          all = next
-          return next
-        })
-        .catch(() => [] as SearchItem[])
-        .finally(() => {
-          inflight = undefined
-        })
-
-      return inflight
-    }
-
-    createEffect(
-      on(
-        () => panelProps.project?.worktree,
-        () => reset(),
-        { defer: true },
-      ),
-    )
-
     return (
       <div
         classList={{
@@ -2918,105 +2808,7 @@ export default function Layout(props: ParentProps) {
                 </div>
               </div>
 
-              <div class="shrink-0 px-2 pt-2">
-                <div
-                  class="flex items-center gap-2 p-2 rounded-md bg-surface-base shadow-xs-border-base focus-within:shadow-xs-border-select"
-                  onPointerDown={(event) => {
-                    const target = event.target
-                    if (!(target instanceof Element)) return
-                    if (target.closest("input, textarea, [contenteditable='true']")) return
-                    searchRef?.focus()
-                  }}
-                >
-                  <Icon name="magnifying-glass" />
-                  <InlineInput
-                    ref={(el) => {
-                      searchRef = el
-                    }}
-                    class="flex-1 min-w-0 text-14-regular text-text-strong placeholder:text-text-weak"
-                    style={{ "box-shadow": "none" }}
-                    value={search.value}
-                    onInput={(event) => setSearch("value", event.currentTarget.value)}
-                    onKeyDown={(event) => {
-                      if (event.key === "Escape") {
-                        event.preventDefault()
-                        setSearch("value", "")
-                        queueMicrotask(() => searchRef?.focus())
-                        return
-                      }
-
-                      if (!searching()) return
-
-                      if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter") {
-                        const ref = listRef
-                        if (!ref) return
-                        event.stopPropagation()
-                        ref.onKeyDown(event)
-                        return
-                      }
-
-                      if (event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
-                        if (event.key === "n" || event.key === "p") {
-                          const ref = listRef
-                          if (!ref) return
-                          event.stopPropagation()
-                          ref.onKeyDown(event)
-                        }
-                      }
-                    }}
-                    placeholder={language.t("session.header.search.placeholder", { project: projectName() })}
-                    spellcheck={false}
-                    autocorrect="off"
-                    autocomplete="off"
-                    autocapitalize="off"
-                  />
-                  <Show when={search.value}>
-                    <IconButton
-                      icon="circle-x"
-                      variant="ghost"
-                      class="size-5"
-                      aria-label={language.t("common.close")}
-                      onClick={() => {
-                        setSearch("value", "")
-                        queueMicrotask(() => searchRef?.focus())
-                      }}
-                    />
-                  </Show>
-                </div>
-              </div>
-
-              <Show when={searching()}>
-                <List
-                  class="flex-1 min-h-0 pb-2 pt-2 !px-2 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
-                  items={items}
-                  filter={search.value}
-                  filterKeys={["title", "label", "id"]}
-                  key={(item) => `${item.directory}:${item.id}`}
-                  onSelect={open}
-                  ref={(ref) => {
-                    listRef = ref
-                  }}
-                >
-                  {(item) => (
-                    <div class="flex flex-col gap-0.5 min-w-0 pr-2 text-left">
-                      <span
-                        class="text-14-medium text-text-strong truncate"
-                        classList={{ "opacity-70": !!item.archived }}
-                      >
-                        {item.title}
-                      </span>
-                      <span
-                        class="text-12-regular text-text-weak truncate"
-                        classList={{ "opacity-70": !!item.archived }}
-                      >
-                        {item.label}
-                      </span>
-                    </div>
-                  )}
-                </List>
-              </Show>
-
-              <div class="flex-1 min-h-0 flex flex-col" classList={{ hidden: searching() }}>
+              <div class="flex-1 min-h-0 flex flex-col">
                 <Show
                   when={workspacesEnabled()}
                   fallback={
@@ -3100,7 +2892,7 @@ export default function Layout(props: ParentProps) {
         <div
           class="shrink-0 px-2 py-3 border-t border-border-weak-base"
           classList={{
-            hidden: searching() || !(providers.all().length > 0 && providers.paid().length === 0),
+            hidden: !(providers.all().length > 0 && providers.paid().length === 0),
           }}
         >
           <div class="rounded-md bg-background-base shadow-xs-border-base">