Bläddra i källkod

feat(app): align cmd k menu with v2 styles (#35152)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
Aarav Sareen 2 månader sedan
förälder
incheckning
26885e7118

+ 325 - 0
packages/app/src/components/command-palette.ts

@@ -0,0 +1,325 @@
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { getFilename } from "@opencode-ai/core/util/path"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useNavigate } from "@solidjs/router"
+import { createMemo, onCleanup } from "solid-js"
+import { useCommand, type CommandOption } from "@/context/command"
+import { useFile } from "@/context/file"
+import { useLanguage } from "@/context/language"
+import { useLayout } from "@/context/layout"
+import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
+import { useServerSync } from "@/context/server-sync"
+import { createSessionTabs } from "@/pages/session/helpers"
+import { useSessionLayout } from "@/pages/session/session-layout"
+import { decode64 } from "@/utils/base64"
+
+export type CommandPaletteEntry = {
+  id: string
+  type: "command" | "file" | "session"
+  title: string
+  description?: string
+  keybind?: string
+  category: string
+  option?: CommandOption
+  path?: string
+  directory?: string
+  sessionID?: string
+  archived?: number
+  updated?: number
+}
+
+const ENTRY_LIMIT = 5
+const COMMON_COMMAND_IDS = [
+  "session.new",
+  "workspace.new",
+  "session.previous",
+  "session.next",
+  "terminal.toggle",
+  "review.toggle",
+] as const
+
+export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) {
+  const seen = new Set<string>()
+  return items.filter((item) => {
+    if (seen.has(item.id)) return false
+    seen.add(item.id)
+    return true
+  })
+}
+
+export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry {
+  return {
+    id: "file:" + path,
+    type: "file",
+    title: path,
+    category,
+    path,
+  }
+}
+
+export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) {
+  const file = useFile()
+  const layout = useLayout()
+  const { tabs, view } = useSessionLayout()
+
+  return (path: string) => {
+    const value = file.tab(path)
+    void tabs().open(value)
+    void file.load(path)
+    if (!view().reviewPanel.opened()) view().reviewPanel.open()
+    layout.fileTree.setTab("all")
+    onOpenFile?.(path)
+    tabs().setActive(value)
+  }
+}
+
+export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
+  const command = useCommand()
+  const language = useLanguage()
+  const layout = useLayout()
+  const file = useFile()
+  const dialog = useDialog()
+  const navigate = useNavigate()
+  const serverSDK = useServerSDK()()
+  const serverSync = useServerSync()
+  const { params, tabs } = useSessionLayout()
+  const openFile = createCommandPaletteFileOpener(props.onOpenFile)
+  const state = { cleanup: undefined as (() => void) | void, committed: false }
+  const filesOnly = () => props.filesOnly?.() ?? false
+
+  const allowedCommands = createMemo(() => {
+    if (filesOnly()) return []
+    return command.options.filter(
+      (option) =>
+        !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
+    )
+  })
+  const commandEntries = createMemo(() => {
+    const category = language.t("palette.group.commands")
+    return allowedCommands().map((option) => createCommandEntry(option, category))
+  })
+  const preferredCommandEntries = createMemo(() => {
+    const all = allowedCommands()
+    const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
+    const picked = all.filter((option) => order.has(option.id))
+    const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
+    const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
+    const category = language.t("palette.group.commands")
+    return sorted.map((option) => createCommandEntry(option, category))
+  })
+
+  const tabState = createSessionTabs({
+    tabs,
+    pathFromTab: file.pathFromTab,
+    normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
+  })
+  const recentFileEntries = createMemo(() => {
+    const all = tabState.openedTabs()
+    const active = tabState.activeFileTab()
+    const order = active ? [active, ...all.filter((item) => item !== active)] : all
+    const seen = new Set<string>()
+    const category = language.t("palette.group.files")
+    return order
+      .map((item) => file.pathFromTab(item))
+      .filter((path): path is string => {
+        if (!path || seen.has(path)) return false
+        seen.add(path)
+        return true
+      })
+      .slice(0, ENTRY_LIMIT)
+      .map((path) => createCommandPaletteFileEntry(path, category))
+  })
+  const rootFileEntries = createMemo(() => {
+    const category = language.t("palette.group.files")
+    return file.tree
+      .children("")
+      .filter((node) => node.type === "file")
+      .map((node) => node.path)
+      .sort((a, b) => a.localeCompare(b))
+      .slice(0, ENTRY_LIMIT)
+      .map((path) => createCommandPaletteFileEntry(path, category))
+  })
+
+  const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
+  const project = createMemo(() => {
+    const directory = projectDirectory()
+    if (!directory) return undefined
+    return layout.projects.list().find((item) => item.worktree === directory || item.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(() => serverSync().data.path.home)
+  const sessions = createSessionEntries({
+    workspaces,
+    label: (directory) => {
+      const current = project()
+      const kind =
+        current && directory === current.worktree
+          ? language.t("workspace.type.local")
+          : language.t("workspace.type.sandbox")
+      const [store] = serverSync().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}`
+    },
+    load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
+    untitled: () => language.t("command.session.new"),
+    category: () => language.t("command.category.session"),
+  })
+
+  const highlight = (item: CommandPaletteEntry | undefined) => {
+    state.cleanup?.()
+    state.cleanup = undefined
+    if (item?.type !== "command") return
+    state.cleanup = item.option?.onHighlight?.()
+  }
+
+  const select = (item: CommandPaletteEntry | undefined) => {
+    if (!item) return
+    state.committed = true
+    state.cleanup = undefined
+    dialog.close()
+    if (item.type === "command") {
+      item.option?.onSelect?.("palette")
+      return
+    }
+    if (item.type === "session") {
+      if (!item.directory || !item.sessionID) return
+      navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
+      return
+    }
+    if (!item.path) return
+    openFile(item.path)
+  }
+
+  onCleanup(() => {
+    if (state.committed) return
+    state.cleanup?.()
+  })
+
+  return {
+    language,
+    file,
+    commandEntries,
+    preferredCommandEntries,
+    recentFileEntries,
+    rootFileEntries,
+    sessions,
+    highlight,
+    select,
+    close: () => dialog.close(),
+  }
+}
+
+function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
+  return {
+    id: "command:" + option.id,
+    type: "command",
+    title: option.title,
+    description: option.description,
+    keybind: option.keybind,
+    category,
+    option,
+  }
+}
+
+function createSessionEntries(props: {
+  workspaces: () => string[]
+  label: (directory: string) => string
+  load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
+  untitled: () => string
+  category: () => string
+}) {
+  const state: {
+    token: number
+    inflight: Promise<CommandPaletteEntry[]> | undefined
+    cached: CommandPaletteEntry[] | undefined
+  } = { token: 0, inflight: undefined, cached: undefined }
+
+  return (text: string) => {
+    if (!text.trim()) {
+      state.token += 1
+      state.inflight = undefined
+      state.cached = undefined
+      return [] as CommandPaletteEntry[]
+    }
+    if (state.cached) return state.cached
+    if (state.inflight) return state.inflight
+
+    const current = state.token
+    const dirs = props.workspaces()
+    if (dirs.length === 0) return [] as CommandPaletteEntry[]
+
+    state.inflight = Promise.all(
+      dirs.map((directory) => {
+        const description = props.label(directory)
+        return props
+          .load(directory)
+          .then((result) =>
+            (result.data ?? [])
+              .filter((session) => !!session?.id)
+              .map((session) => ({
+                id: session.id,
+                title: session.title ?? props.untitled(),
+                description,
+                directory,
+                archived: session.time?.archived,
+                updated: session.time?.updated,
+              })),
+          )
+          .catch(() => [] as SessionEntryInput[])
+      }),
+    )
+      .then((results) => {
+        if (state.token !== current) return [] as CommandPaletteEntry[]
+        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((item) => createSessionEntry(item, props.category()))
+        state.cached = next
+        return next
+      })
+      .catch(() => [] as CommandPaletteEntry[])
+      .finally(() => {
+        state.inflight = undefined
+      })
+
+    return state.inflight
+  }
+}
+
+type SessionEntryInput = {
+  directory: string
+  id: string
+  title: string
+  description: string
+  archived?: number
+  updated?: number
+}
+
+function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
+  return {
+    id: `session:${input.directory}:${input.id}`,
+    type: "session",
+    title: input.title,
+    description: input.description,
+    category,
+    directory: input.directory,
+    sessionID: input.id,
+    archived: input.archived,
+    updated: input.updated,
+  }
+}

+ 220 - 0
packages/app/src/components/dialog-command-palette-v2.css

@@ -0,0 +1,220 @@
+.command-palette-v2 {
+  overflow: hidden;
+}
+
+/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the
+   top stays put while the content-driven height grows and shrinks. */
+[data-component="dialog-v2"]:has(.command-palette-v2) {
+  align-items: flex-start;
+}
+
+[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] {
+  width: min(calc(100vw - 24px), 640px);
+  height: auto;
+  min-height: 280px;
+  max-height: min(calc(100vh - 96px), 480px);
+  margin-top: max(48px, calc((100vh - 480px) / 2));
+  border-radius: 12px;
+  background: var(--v2-background-bg-base);
+  box-shadow: var(--v2-elevation-floating);
+}
+
+.command-palette-v2-body {
+  display: flex;
+  min-height: 0;
+  flex: 1;
+  flex-direction: column;
+  gap: 0;
+  padding: 0;
+}
+
+.command-palette-v2-search {
+  flex-shrink: 0;
+  padding: 6px;
+}
+
+.command-palette-v2-search [data-component="text-input-v2"] {
+  width: 100%;
+  height: 36px;
+  border-radius: 6px;
+  outline: 0;
+  background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent);
+  box-shadow: none;
+  transition:
+    background-color 120ms ease-in-out,
+    box-shadow 120ms ease-in-out;
+}
+
+.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
+.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
+  background: var(--v2-background-bg-layer-02);
+  box-shadow: none;
+}
+
+.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
+  border-radius: 6px;
+  background: transparent;
+  gap: 8px;
+}
+
+.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
+  padding-left: 12px;
+}
+
+.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
+  border-radius: 6px;
+  background: transparent;
+}
+
+.command-palette-v2-scroll {
+  min-height: 0;
+  flex: 1;
+}
+
+.command-palette-v2-results {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  padding: 6px 6px 8px;
+}
+
+.command-palette-v2-group {
+  display: flex;
+  flex-direction: column;
+  gap: 1px;
+}
+
+.command-palette-v2-group-title {
+  margin: 6px 0;
+  padding: 0 12px;
+  color: var(--v2-text-text-muted);
+  font-size: 13px;
+  font-weight: 440;
+  line-height: 16px;
+  letter-spacing: -0.04px;
+  user-select: none;
+}
+
+.command-palette-v2-row {
+  display: flex;
+  width: 100%;
+  height: 36px;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  padding: 0 12px;
+  border: 0;
+  border-radius: 6px;
+  background: transparent;
+  color: var(--v2-text-text-base);
+  text-align: left;
+  cursor: default;
+  scroll-margin: 6px 0;
+}
+
+.command-palette-v2-row[data-active] {
+  background: var(--v2-overlay-simple-overlay-hover);
+}
+
+.command-palette-v2-row:focus-visible {
+  background: var(--v2-overlay-simple-overlay-hover);
+  outline: none;
+}
+
+.command-palette-v2-row-main {
+  display: flex;
+  min-width: 0;
+  flex: 1;
+  align-items: center;
+  gap: 8px;
+}
+
+.command-palette-v2-row-icon {
+  flex-shrink: 0;
+  color: var(--v2-icon-icon-muted);
+}
+
+.command-palette-v2-row-text {
+  display: flex;
+  min-width: 0;
+  align-items: center;
+  gap: 6px;
+}
+
+.command-palette-v2-title {
+  min-width: 0;
+  overflow: hidden;
+  color: var(--v2-text-text-base);
+  font-size: 13px;
+  font-weight: 530;
+  line-height: 16px;
+  letter-spacing: -0.04px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.command-palette-v2-description,
+.command-palette-v2-meta {
+  min-width: 0;
+  overflow: hidden;
+  color: var(--v2-text-text-muted);
+  font-size: 13px;
+  font-weight: 440;
+  line-height: 16px;
+  letter-spacing: -0.04px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.command-palette-v2-meta {
+  flex-shrink: 0;
+}
+
+.command-palette-v2-file-path {
+  display: flex;
+  min-width: 0;
+  align-items: baseline;
+  font-size: 13px;
+  line-height: 16px;
+  letter-spacing: -0.04px;
+}
+
+.command-palette-v2-file-dir {
+  min-width: 0;
+  overflow: hidden;
+  color: var(--v2-text-text-muted);
+  font-weight: 440;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.command-palette-v2-file-name {
+  flex-shrink: 0;
+  color: var(--v2-text-text-base);
+  font-weight: 530;
+  white-space: nowrap;
+}
+
+.command-palette-v2-state {
+  display: grid;
+  min-height: 120px;
+  place-items: center;
+  color: var(--v2-text-text-muted);
+  font-size: 13px;
+  font-weight: 440;
+  line-height: 16px;
+  letter-spacing: -0.04px;
+}
+
+@media (max-width: 640px) {
+  .command-palette-v2-row-text {
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 1px;
+  }
+
+  .command-palette-v2-description {
+    max-width: 100%;
+  }
+}

+ 221 - 0
packages/app/src/components/dialog-command-palette-v2.tsx

@@ -0,0 +1,221 @@
+import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { ScrollView } from "@opencode-ai/ui/scroll-view"
+import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
+import { Icon } from "@opencode-ai/ui/v2/icon"
+import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
+import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
+import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
+import { formatKeybindParts } from "@/context/command"
+import { useLanguage } from "@/context/language"
+import { getRelativeTime } from "@/utils/time"
+import {
+  createCommandPaletteFileEntry,
+  createCommandPaletteModel,
+  uniqueCommandPaletteEntries,
+  type CommandPaletteEntry,
+} from "./command-palette"
+import "./dialog-command-palette-v2.css"
+
+function groups(entries: CommandPaletteEntry[]) {
+  const map = new Map<string, CommandPaletteEntry[]>()
+  for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry])
+  return Array.from(map.entries()).map(([category, entries]) => ({ category, entries }))
+}
+
+function matchesEntry(entry: CommandPaletteEntry, query: string) {
+  const value = query.toLowerCase()
+  return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value))
+}
+
+export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
+  const palette = createCommandPaletteModel(props)
+  const [query, setQuery] = createSignal("")
+  const [active, setActive] = createSignal(0)
+
+  const loadItems = async (text: string) => {
+    const q = text.trim()
+    if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
+
+    const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
+    const category = palette.language.t("palette.group.files")
+    return [
+      ...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
+      ...nextSessions.filter((entry) => matchesEntry(entry, q)),
+      ...files.map((path) => createCommandPaletteFileEntry(path, category)),
+    ]
+  }
+
+  const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
+  // Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
+  const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
+  const groupedEntries = createMemo(() => groups(visibleEntries()))
+  const activeEntry = createMemo(() => visibleEntries()[active()])
+
+  createEffect(() => {
+    query()
+    visibleEntries()
+    setActive(0)
+  })
+
+  createEffect(() => {
+    palette.highlight(activeEntry())
+  })
+
+  let resultsRef: HTMLDivElement | undefined
+
+  const move = (delta: -1 | 1) => {
+    const count = visibleEntries().length
+    if (count === 0) return
+    setActive((index) => (index + delta + count) % count)
+    requestAnimationFrame(() => {
+      resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" })
+    })
+  }
+
+  const handleKeyDown = (event: KeyboardEvent) => {
+    if (event.key === "ArrowDown") {
+      event.preventDefault()
+      move(1)
+      return
+    }
+    if (event.key === "ArrowUp") {
+      event.preventDefault()
+      move(-1)
+      return
+    }
+    if (event.key === "Enter") {
+      event.preventDefault()
+      palette.select(activeEntry())
+      return
+    }
+    if (event.key === "Escape") {
+      event.preventDefault()
+      palette.close()
+    }
+  }
+
+  return (
+    <Dialog class="command-palette-v2" size="large">
+      <DialogBody class="command-palette-v2-body">
+        <div class="command-palette-v2-search">
+          <TextInputV2
+            value={query()}
+            autofocus
+            autocomplete="off"
+            spellcheck={false}
+            appearance="large"
+            placeholder={palette.language.t("palette.search.placeholder")}
+            leadingIcon={<Icon name="magnifying-glass" />}
+            onInput={(event) => setQuery(event.currentTarget.value)}
+            onKeyDown={handleKeyDown}
+          />
+        </div>
+        <ScrollView class="command-palette-v2-scroll" viewportRef={(el) => (resultsRef = el)}>
+          <div class="command-palette-v2-results" role="listbox">
+            <Show
+              when={visibleEntries().length > 0}
+              fallback={
+                <div class="command-palette-v2-state">
+                  {entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
+                </div>
+              }
+            >
+              <For each={groupedEntries()}>
+                {(group) => (
+                  <div class="command-palette-v2-group">
+                    <Show when={group.category}>
+                      <div class="command-palette-v2-group-title">{group.category}</div>
+                    </Show>
+                    <For each={group.entries}>
+                      {(item) => (
+                        <PaletteRow
+                          item={item}
+                          active={activeEntry()?.id === item.id}
+                          language={palette.language}
+                          onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
+                          onSelect={() => palette.select(item)}
+                        />
+                      )}
+                    </For>
+                  </div>
+                )}
+              </For>
+            </Show>
+          </div>
+        </ScrollView>
+      </DialogBody>
+    </Dialog>
+  )
+}
+
+function PaletteRow(props: {
+  item: CommandPaletteEntry
+  active: boolean
+  language: ReturnType<typeof useLanguage>
+  onActive: () => void
+  onSelect: () => void
+}) {
+  return (
+    <button
+      type="button"
+      class="command-palette-v2-row"
+      role="option"
+      aria-selected={props.active}
+      data-active={props.active ? "" : undefined}
+      onMouseMove={(event) => {
+        // Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
+        if (event.movementX === 0 && event.movementY === 0) return
+        props.onActive()
+      }}
+      onMouseDown={(event) => event.preventDefault()}
+      onClick={props.onSelect}
+    >
+      <Switch
+        fallback={
+          <div class="command-palette-v2-row-main">
+            <FileIcon node={{ path: props.item.path ?? "", type: "file" }} class="command-palette-v2-row-icon size-4" />
+            <div class="command-palette-v2-file-path">
+              <span class="command-palette-v2-file-dir">{getDirectory(props.item.path ?? "")}</span>
+              <span class="command-palette-v2-file-name">{getFilename(props.item.path ?? "")}</span>
+            </div>
+          </div>
+        }
+      >
+        <Match when={props.item.type === "command"}>
+          <div class="command-palette-v2-row-main">
+            <div class="command-palette-v2-row-text">
+              <span class="command-palette-v2-title">{props.item.title}</span>
+              <Show when={props.item.description}>
+                <span class="command-palette-v2-description">{props.item.description}</span>
+              </Show>
+            </div>
+          </div>
+          <Show when={props.item.keybind}>
+            <KeybindV2 keys={formatKeybindParts(props.item.keybind ?? "", props.language.t)} variant="neutral" />
+          </Show>
+        </Match>
+        <Match when={props.item.type === "session"}>
+          <div class="command-palette-v2-row-main">
+            <Icon name="status" class="command-palette-v2-row-icon" />
+            <div class="command-palette-v2-row-text">
+              <span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
+                {props.item.title}
+              </span>
+              <Show when={props.item.description}>
+                <span class="command-palette-v2-description" classList={{ "opacity-70": !!props.item.archived }}>
+                  {props.item.description}
+                </span>
+              </Show>
+            </div>
+          </div>
+          <Show when={props.item.updated}>
+            <span class="command-palette-v2-meta">
+              {getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)}
+            </span>
+          </Show>
+        </Match>
+      </Switch>
+    </button>
+  )
+}

+ 67 - 367
packages/app/src/components/dialog-select-file.tsx

@@ -1,329 +1,84 @@
-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/core/util/encode"
 import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
-import { useNavigate } from "@solidjs/router"
-import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js"
-import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
-import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
-import { useServerSync } from "@/context/server-sync"
-import { useLayout } from "@/context/layout"
-import { useFile } from "@/context/file"
+import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
+import { formatKeybind } from "@/context/command"
+import { useServerSDK } from "@/context/server-sdk"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { useSettings } from "@/context/settings"
 import { useSessionLayout } from "@/pages/session/session-layout"
-import { createSessionTabs } from "@/pages/session/helpers"
 import { decode64 } from "@/utils/base64"
 import { getRelativeTime } from "@/utils/time"
+import {
+  createCommandPaletteFileEntry,
+  createCommandPaletteFileOpener,
+  createCommandPaletteModel,
+  uniqueCommandPaletteEntries,
+  type CommandPaletteEntry,
+} from "./command-palette"
 
 const DialogSelectFileV2 = lazy(() =>
   import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
 )
-
-type EntryType = "command" | "file" | "session"
-
-type Entry = {
-  id: string
-  type: EntryType
-  title: string
-  description?: string
-  keybind?: string
-  category: string
-  option?: CommandOption
-  path?: string
-  directory?: string
-  sessionID?: string
-  archived?: number
-  updated?: number
-}
+const DialogCommandPaletteV2 = lazy(() =>
+  import("./dialog-command-palette-v2").then((module) => ({ default: module.DialogCommandPaletteV2 })),
+)
 
 type DialogSelectFileMode = "all" | "files"
 
-const ENTRY_LIMIT = 5
-const COMMON_COMMAND_IDS = [
-  "session.new",
-  "workspace.new",
-  "session.previous",
-  "session.next",
-  "terminal.toggle",
-  "review.toggle",
-] as const
-
-const uniqueEntries = (items: Entry[]) => {
-  const seen = new Set<string>()
-  const out: Entry[] = []
-  for (const item of items) {
-    if (seen.has(item.id)) continue
-    seen.add(item.id)
-    out.push(item)
-  }
-  return out
-}
-
-const createCommandEntry = (option: CommandOption, category: string): Entry => ({
-  id: "command:" + option.id,
-  type: "command",
-  title: option.title,
-  description: option.description,
-  keybind: option.keybind,
-  category,
-  option,
-})
-
-const createFileEntry = (path: string, category: string): Entry => ({
-  id: "file:" + path,
-  type: "file",
-  title: path,
-  category,
-  path,
-})
-
-const createSessionEntry = (
-  input: {
-    directory: string
-    id: string
-    title: string
-    description: string
-    archived?: number
-    updated?: number
-  },
-  category: string,
-): Entry => ({
-  id: `session:${input.directory}:${input.id}`,
-  type: "session",
-  title: input.title,
-  description: input.description,
-  category,
-  directory: input.directory,
-  sessionID: input.id,
-  archived: input.archived,
-  updated: input.updated,
-})
-
-function createCommandEntries(props: {
-  filesOnly: () => boolean
-  command: ReturnType<typeof useCommand>
-  language: ReturnType<typeof useLanguage>
-}) {
-  const allowed = createMemo(() => {
-    if (props.filesOnly()) return []
-    return props.command.options.filter(
-      (option) =>
-        !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
-    )
-  })
-
-  const list = createMemo(() => {
-    const category = props.language.t("palette.group.commands")
-    return allowed().map((option) => createCommandEntry(option, category))
-  })
-
-  const picks = createMemo(() => {
-    const all = allowed()
-    const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
-    const picked = all.filter((option) => order.has(option.id))
-    const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
-    const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
-    const category = props.language.t("palette.group.commands")
-    return sorted.map((option) => createCommandEntry(option, category))
-  })
-
-  return { allowed, list, picks }
-}
-
-function createFileEntries(props: {
-  file: ReturnType<typeof useFile>
-  tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
-  language: ReturnType<typeof useLanguage>
-}) {
-  const tabState = createSessionTabs({
-    tabs: props.tabs,
-    pathFromTab: props.file.pathFromTab,
-    normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
-  })
-  const recent = createMemo(() => {
-    const all = tabState.openedTabs()
-    const active = tabState.activeFileTab()
-    const order = active ? [active, ...all.filter((item) => item !== active)] : all
-    const seen = new Set<string>()
-    const category = props.language.t("palette.group.files")
-    const items: Entry[] = []
-
-    for (const item of order) {
-      const path = props.file.pathFromTab(item)
-      if (!path) continue
-      if (seen.has(path)) continue
-      seen.add(path)
-      items.push(createFileEntry(path, category))
-    }
-
-    return items.slice(0, ENTRY_LIMIT)
-  })
-
-  const root = createMemo(() => {
-    const category = props.language.t("palette.group.files")
-    const nodes = props.file.tree.children("")
-    const paths = nodes
-      .filter((node) => node.type === "file")
-      .map((node) => node.path)
-      .sort((a, b) => a.localeCompare(b))
-    return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
-  })
-
-  return { recent, root }
-}
+export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
+  const platform = usePlatform()
+  const settings = useSettings()
+  const filesOnly = () => props.mode === "files"
 
-function createSessionEntries(props: {
-  workspaces: () => string[]
-  label: (directory: string) => string
-  serverSDK: ServerSDK
-  language: ReturnType<typeof useLanguage>
-}) {
-  const state: {
-    token: number
-    inflight: Promise<Entry[]> | undefined
-    cached: Entry[] | undefined
-  } = {
-    token: 0,
-    inflight: undefined,
-    cached: undefined,
+  if (!filesOnly() && settings.general.newLayoutDesigns()) {
+    return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
   }
 
-  const sessions = (text: string) => {
-    const query = text.trim()
-    if (!query) {
-      state.token += 1
-      state.inflight = undefined
-      state.cached = undefined
-      return [] as Entry[]
-    }
-
-    if (state.cached) return state.cached
-    if (state.inflight) return state.inflight
-
-    const current = state.token
-    const dirs = props.workspaces()
-    if (dirs.length === 0) return [] as Entry[]
-
-    state.inflight = Promise.all(
-      dirs.map((directory) => {
-        const description = props.label(directory)
-        return props.serverSDK.client.session
-          .list({ directory, roots: true })
-          .then((x) =>
-            (x.data ?? [])
-              .filter((s) => !!s?.id)
-              .map((s) => ({
-                id: s.id,
-                title: s.title ?? props.language.t("command.session.new"),
-                description,
-                directory,
-                archived: s.time?.archived,
-                updated: s.time?.updated,
-              })),
-          )
-          .catch(
-            () =>
-              [] as {
-                id: string
-                title: string
-                description: string
-                directory: string
-                archived?: number
-                updated?: number
-              }[],
-          )
-      }),
-    )
-      .then((results) => {
-        if (state.token !== current) return [] as Entry[]
-        const seen = new Set<string>()
-        const category = props.language.t("command.category.session")
-        const next = results
-          .flat()
-          .filter((item) => {
-            const key = `${item.directory}:${item.id}`
-            if (seen.has(key)) return false
-            seen.add(key)
-            return true
-          })
-          .map((item) => createSessionEntry(item, category))
-        state.cached = next
-        return next
-      })
-      .catch(() => [] as Entry[])
-      .finally(() => {
-        state.inflight = undefined
-      })
-
-    return state.inflight
+  if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
+    return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
   }
 
-  return { sessions }
+  return <DialogSelectFileLegacy filesOnly={filesOnly} onOpenFile={props.onOpenFile} />
 }
 
-export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
-  const command = useCommand()
+function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) {
   const language = useLanguage()
-  const platform = usePlatform()
-  const settings = useSettings()
-  const layout = useLayout()
-  const file = useFile()
-  const dialog = useDialog()
-  const navigate = useNavigate()
   const serverSDK = useServerSDK()
-  const serverSync = useServerSync()
-  const { params, tabs, view } = useSessionLayout()
-  const filesOnly = () => props.mode === "files"
-  const state = { cleanup: undefined as (() => void) | void, committed: false }
-  const [grouped, setGrouped] = createSignal(false)
-  const commandEntries = createCommandEntries({ filesOnly, command, language })
-  const fileEntries = createFileEntries({ file, tabs, language })
-
+  const { params } = useSessionLayout()
   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 openFile = createCommandPaletteFileOpener(props.onOpenFile)
 
-    const dirs = [current.worktree, ...(current.sandboxes ?? [])]
-    if (directory && !dirs.includes(directory)) return [...dirs, directory]
-    return dirs
-  })
-  const homedir = createMemo(() => serverSync().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] = serverSync().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}`
-  }
+  return (
+    <DialogSelectFileV2
+      server={serverSDK().server}
+      mode="file"
+      start={projectDirectory()}
+      title={language.t("session.header.searchFiles")}
+      onSelect={(result) => {
+        if (typeof result !== "string") return
+        openFile(result)
+      }}
+    />
+  )
+}
 
-  const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
+function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) {
+  const palette = createCommandPaletteModel(props)
+  const [grouped, setGrouped] = createSignal(false)
 
   const items = async (text: string) => {
     const query = text.trim()
     setGrouped(query.length > 0)
 
-    if (!query && filesOnly()) {
-      const loaded = file.tree.state("")?.loaded
-      const pending = loaded ? Promise.resolve() : file.tree.list("")
-      const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
+    if (!query && props.filesOnly()) {
+      const loaded = palette.file.tree.state("")?.loaded
+      const pending = loaded ? Promise.resolve() : palette.file.tree.list("")
+      const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
 
       if (loaded || next.length > 0) {
         void pending
@@ -331,79 +86,24 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
       }
 
       await pending
-      return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
-    }
-
-    if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
-
-    if (filesOnly()) {
-      const files = await file.searchFiles(query)
-      const category = language.t("palette.group.files")
-      return files.map((path) => createFileEntry(path, category))
+      return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
     }
 
-    const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
-    const category = language.t("palette.group.files")
-    const entries = files.map((path) => createFileEntry(path, category))
-    return [...commandEntries.list(), ...nextSessions, ...entries]
-  }
-
-  const handleMove = (item: Entry | undefined) => {
-    state.cleanup?.()
-    if (!item) return
-    if (item.type !== "command") return
-    state.cleanup = item.option?.onHighlight?.()
-  }
-
-  const open = (path: string) => {
-    const value = file.tab(path)
-    void tabs().open(value)
-    void file.load(path)
-    if (!view().reviewPanel.opened()) view().reviewPanel.open()
-    layout.fileTree.setTab("all")
-    props.onOpenFile?.(path)
-    tabs().setActive(value)
-  }
-
-  const handleSelect = (item: Entry | undefined) => {
-    if (!item) return
-    state.committed = true
-    state.cleanup = undefined
-    dialog.close()
+    if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
 
-    if (item.type === "command") {
-      item.option?.onSelect?.("palette")
-      return
+    if (props.filesOnly()) {
+      const files = await palette.file.searchFiles(query)
+      const category = palette.language.t("palette.group.files")
+      return files.map((path) => createCommandPaletteFileEntry(path, category))
     }
 
-    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)
-  }
-
-  onCleanup(() => {
-    if (state.committed) return
-    state.cleanup?.()
-  })
-
-  if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
-    return (
-      <DialogSelectFileV2
-        server={serverSDK().server}
-        mode="file"
-        start={projectDirectory()}
-        title={language.t("session.header.searchFiles")}
-        onSelect={(result) => {
-          if (typeof result !== "string") return
-          open(result)
-        }}
-      />
-    )
+    const [files, nextSessions] = await Promise.all([
+      palette.file.searchFiles(query),
+      Promise.resolve(palette.sessions(query)),
+    ])
+    const category = palette.language.t("palette.group.files")
+    const entries = files.map((path) => createCommandPaletteFileEntry(path, category))
+    return [...palette.commandEntries(), ...nextSessions, ...entries]
   }
 
   return (
@@ -411,21 +111,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
       <List
         class="px-3"
         search={{
-          placeholder: filesOnly()
-            ? language.t("session.header.searchFiles")
-            : language.t("palette.search.placeholder"),
+          placeholder: props.filesOnly()
+            ? palette.language.t("session.header.searchFiles")
+            : palette.language.t("palette.search.placeholder"),
           autofocus: true,
           hideIcon: true,
         }}
-        emptyMessage={language.t("palette.empty")}
-        loadingMessage={language.t("common.loading")}
+        emptyMessage={palette.language.t("palette.empty")}
+        loadingMessage={palette.language.t("common.loading")}
         items={items}
         key={(item) => item.id}
         filterKeys={["title", "description", "category"]}
         skipFilter={(item) => item.type === "file"}
         groupBy={grouped() ? (item) => item.category : () => ""}
-        onMove={handleMove}
-        onSelect={handleSelect}
+        onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)}
+        onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)}
       >
         {(item) => (
           <Switch
@@ -452,7 +152,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
                   </Show>
                 </div>
                 <Show when={item.keybind}>
-                  <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
+                  <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", palette.language.t)}</Keybind>
                 </Show>
               </div>
             </Match>
@@ -479,7 +179,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
                 </div>
                 <Show when={item.updated}>
                   <span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
-                    {getRelativeTime(new Date(item.updated!).toISOString(), language.t)}
+                    {getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)}
                   </span>
                 </Show>
               </div>