|
|
@@ -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>
|