| import { getFilename } from "@opencode-ai/core/util/path" |
| import type { Project } from "@opencode-ai/sdk/v2/client" |
| import type { SessionInfo } from "@opencode-ai/client/promise" |
| import { useDialog } from "@opencode-ai/ui/context/dialog" |
| import { createMemo, onCleanup } from "solid-js" |
| import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command" |
| import { useFile } from "@/context/file" |
| import { useGlobal } from "@/context/global" |
| import { useLanguage } from "@/context/language" |
| import { useLayout, type LocalProject } from "@/context/layout" |
| import { ServerConnection } from "@/context/server" |
| import { useServerSDK } from "@/context/server-sdk" |
| import { useTabs } from "@/context/tabs" |
| import { displayName, projectForSession } from "@/pages/layout/helpers" |
| import { createSessionTabs } from "@/pages/session/helpers" |
| import { useSessionLayout } from "@/pages/session/session-layout" |
| import { normalizeSessionInfo } from "@/utils/session" |
|
|
| 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 |
| server?: ServerConnection.Key |
| project?: LocalProject |
| 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 global = useGlobal() |
| const language = useLanguage() |
| const file = useFile() |
| const dialog = useDialog() |
| const serverSDK = useServerSDK()() |
| const serverCtx = global.ensureServerCtx(serverSDK.server) |
| const appTabs = useTabs() |
| const { tabs: sessionTabs } = 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 commandPaletteOptions(command.options) |
| }) |
| const commandEntries = createMemo(() => { |
| const category = language.t("palette.group.commands") |
| return allowedCommands().map((option) => createCommandPaletteCommandEntry(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) => createCommandPaletteCommandEntry(option, category)) |
| }) |
|
|
| const tabState = createSessionTabs({ |
| tabs: sessionTabs, |
| 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 sessions = createServerSessionEntries({ |
| server: ServerConnection.key(serverSDK.server), |
| opened: serverCtx.projects.list, |
| stored: () => serverCtx.sync.data.project, |
| load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }), |
| 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.sessionID || !item.server) return |
| const directory = item.project?.worktree ?? item.directory |
| if (directory) { |
| serverCtx.projects.open(directory) |
| serverCtx.projects.touch(directory) |
| } |
| const tab = appTabs.addSessionTab({ |
| server: item.server, |
| sessionId: item.sessionID, |
| }) |
| appTabs.select(tab) |
| 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(), |
| } |
| } |
|
|
| export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry { |
| return { |
| id: "command:" + option.id, |
| type: "command", |
| title: option.title, |
| description: option.description, |
| keybind: option.keybind, |
| category, |
| option, |
| } |
| } |
|
|
| export function createServerSessionEntries(props: { |
| server: ServerConnection.Key |
| opened: () => LocalProject[] |
| stored: () => Project[] |
| load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }> |
| untitled: () => string |
| category: () => string |
| }) { |
| let abort: AbortController | undefined |
|
|
| onCleanup(() => abort?.abort()) |
|
|
| return async (text: string): Promise<CommandPaletteEntry[]> => { |
| const search = text.trim() |
| if (!search) { |
| abort?.abort() |
| return [] |
| } |
| abort?.abort() |
| const current = new AbortController() |
| abort = current |
| await new Promise<void>((resolve) => { |
| const timer = setTimeout(resolve, 100) |
| current.signal.addEventListener( |
| "abort", |
| () => { |
| clearTimeout(timer) |
| resolve() |
| }, |
| { once: true }, |
| ) |
| }) |
| if (current.signal.aborted) return [] |
| const opened = props.opened() |
| const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : []))) |
| const stored = props.stored().map((project) => ({ ...project, expanded: false })) |
| const storedByID = new Map(stored.map((project) => [project.id, project] as const)) |
| return props |
| .load(search, current.signal) |
| .then((result) => |
| result.data |
| .map(normalizeSessionInfo) |
| .filter((session) => !session.time.archived) |
| .map((session) => { |
| const project = |
| projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID) |
| return { |
| id: `session:${props.server}:${session.id}`, |
| type: "session" as const, |
| title: session.title || props.untitled(), |
| description: project ? displayName(project) : getFilename(session.directory), |
| category: props.category(), |
| directory: session.directory, |
| sessionID: session.id, |
| server: props.server, |
| project, |
| updated: session.time.updated, |
| } |
| }), |
| ) |
| .catch(() => [] as CommandPaletteEntry[]) |
| } |
| } |
|
|