command-palette.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import { getFilename } from "@opencode-ai/core/util/path"
  2. import type { Project } from "@opencode-ai/sdk/v2/client"
  3. import type { SessionInfo } from "@opencode-ai/client/promise"
  4. import { useDialog } from "@opencode-ai/ui/context/dialog"
  5. import { createMemo, onCleanup } from "solid-js"
  6. import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
  7. import { useFile } from "@/context/file"
  8. import { useGlobal } from "@/context/global"
  9. import { useLanguage } from "@/context/language"
  10. import { useLayout, type LocalProject } from "@/context/layout"
  11. import { ServerConnection } from "@/context/server"
  12. import { useServerSDK } from "@/context/server-sdk"
  13. import { useTabs } from "@/context/tabs"
  14. import { displayName, projectForSession } from "@/pages/layout/helpers"
  15. import { createSessionTabs } from "@/pages/session/helpers"
  16. import { useSessionLayout } from "@/pages/session/session-layout"
  17. import { normalizeSessionInfo } from "@/utils/session"
  18. export type CommandPaletteEntry = {
  19. id: string
  20. type: "command" | "file" | "session"
  21. title: string
  22. description?: string
  23. keybind?: string
  24. category: string
  25. option?: CommandOption
  26. path?: string
  27. directory?: string
  28. sessionID?: string
  29. server?: ServerConnection.Key
  30. project?: LocalProject
  31. archived?: number
  32. updated?: number
  33. }
  34. const ENTRY_LIMIT = 5
  35. const COMMON_COMMAND_IDS = [
  36. "session.new",
  37. "workspace.new",
  38. "session.previous",
  39. "session.next",
  40. "terminal.toggle",
  41. "review.toggle",
  42. ] as const
  43. export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) {
  44. const seen = new Set<string>()
  45. return items.filter((item) => {
  46. if (seen.has(item.id)) return false
  47. seen.add(item.id)
  48. return true
  49. })
  50. }
  51. export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry {
  52. return {
  53. id: "file:" + path,
  54. type: "file",
  55. title: path,
  56. category,
  57. path,
  58. }
  59. }
  60. export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) {
  61. const file = useFile()
  62. const layout = useLayout()
  63. const { tabs, view } = useSessionLayout()
  64. return (path: string) => {
  65. const value = file.tab(path)
  66. void tabs().open(value)
  67. void file.load(path)
  68. if (!view().reviewPanel.opened()) view().reviewPanel.open()
  69. layout.fileTree.setTab("all")
  70. onOpenFile?.(path)
  71. tabs().setActive(value)
  72. }
  73. }
  74. export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
  75. const command = useCommand()
  76. const global = useGlobal()
  77. const language = useLanguage()
  78. const file = useFile()
  79. const dialog = useDialog()
  80. const serverSDK = useServerSDK()()
  81. const serverCtx = global.ensureServerCtx(serverSDK.server)
  82. const appTabs = useTabs()
  83. const { tabs: sessionTabs } = useSessionLayout()
  84. const openFile = createCommandPaletteFileOpener(props.onOpenFile)
  85. const state = { cleanup: undefined as (() => void) | void, committed: false }
  86. const filesOnly = () => props.filesOnly?.() ?? false
  87. const allowedCommands = createMemo(() => {
  88. if (filesOnly()) return []
  89. return commandPaletteOptions(command.options)
  90. })
  91. const commandEntries = createMemo(() => {
  92. const category = language.t("palette.group.commands")
  93. return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category))
  94. })
  95. const preferredCommandEntries = createMemo(() => {
  96. const all = allowedCommands()
  97. const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
  98. const picked = all.filter((option) => order.has(option.id))
  99. const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
  100. const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
  101. const category = language.t("palette.group.commands")
  102. return sorted.map((option) => createCommandPaletteCommandEntry(option, category))
  103. })
  104. const tabState = createSessionTabs({
  105. tabs: sessionTabs,
  106. pathFromTab: file.pathFromTab,
  107. normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
  108. })
  109. const recentFileEntries = createMemo(() => {
  110. const all = tabState.openedTabs()
  111. const active = tabState.activeFileTab()
  112. const order = active ? [active, ...all.filter((item) => item !== active)] : all
  113. const seen = new Set<string>()
  114. const category = language.t("palette.group.files")
  115. return order
  116. .map((item) => file.pathFromTab(item))
  117. .filter((path): path is string => {
  118. if (!path || seen.has(path)) return false
  119. seen.add(path)
  120. return true
  121. })
  122. .slice(0, ENTRY_LIMIT)
  123. .map((path) => createCommandPaletteFileEntry(path, category))
  124. })
  125. const rootFileEntries = createMemo(() => {
  126. const category = language.t("palette.group.files")
  127. return file.tree
  128. .children("")
  129. .filter((node) => node.type === "file")
  130. .map((node) => node.path)
  131. .sort((a, b) => a.localeCompare(b))
  132. .slice(0, ENTRY_LIMIT)
  133. .map((path) => createCommandPaletteFileEntry(path, category))
  134. })
  135. const sessions = createServerSessionEntries({
  136. server: ServerConnection.key(serverSDK.server),
  137. opened: serverCtx.projects.list,
  138. stored: () => serverCtx.sync.data.project,
  139. load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
  140. untitled: () => language.t("command.session.new"),
  141. category: () => language.t("command.category.session"),
  142. })
  143. const highlight = (item: CommandPaletteEntry | undefined) => {
  144. state.cleanup?.()
  145. state.cleanup = undefined
  146. if (item?.type !== "command") return
  147. state.cleanup = item.option?.onHighlight?.()
  148. }
  149. const select = (item: CommandPaletteEntry | undefined) => {
  150. if (!item) return
  151. state.committed = true
  152. state.cleanup = undefined
  153. dialog.close()
  154. if (item.type === "command") {
  155. item.option?.onSelect?.("palette")
  156. return
  157. }
  158. if (item.type === "session") {
  159. if (!item.sessionID || !item.server) return
  160. const directory = item.project?.worktree ?? item.directory
  161. if (directory) {
  162. serverCtx.projects.open(directory)
  163. serverCtx.projects.touch(directory)
  164. }
  165. const tab = appTabs.addSessionTab({
  166. server: item.server,
  167. sessionId: item.sessionID,
  168. })
  169. appTabs.select(tab)
  170. return
  171. }
  172. if (!item.path) return
  173. openFile(item.path)
  174. }
  175. onCleanup(() => {
  176. if (state.committed) return
  177. state.cleanup?.()
  178. })
  179. return {
  180. language,
  181. file,
  182. commandEntries,
  183. preferredCommandEntries,
  184. recentFileEntries,
  185. rootFileEntries,
  186. sessions,
  187. highlight,
  188. select,
  189. close: () => dialog.close(),
  190. }
  191. }
  192. export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
  193. return {
  194. id: "command:" + option.id,
  195. type: "command",
  196. title: option.title,
  197. description: option.description,
  198. keybind: option.keybind,
  199. category,
  200. option,
  201. }
  202. }
  203. export function createServerSessionEntries(props: {
  204. server: ServerConnection.Key
  205. opened: () => LocalProject[]
  206. stored: () => Project[]
  207. load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
  208. untitled: () => string
  209. category: () => string
  210. }) {
  211. let abort: AbortController | undefined
  212. onCleanup(() => abort?.abort())
  213. return async (text: string): Promise<CommandPaletteEntry[]> => {
  214. const search = text.trim()
  215. if (!search) {
  216. abort?.abort()
  217. return []
  218. }
  219. abort?.abort()
  220. const current = new AbortController()
  221. abort = current
  222. await new Promise<void>((resolve) => {
  223. const timer = setTimeout(resolve, 100)
  224. current.signal.addEventListener(
  225. "abort",
  226. () => {
  227. clearTimeout(timer)
  228. resolve()
  229. },
  230. { once: true },
  231. )
  232. })
  233. if (current.signal.aborted) return []
  234. const opened = props.opened()
  235. const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
  236. const stored = props.stored().map((project) => ({ ...project, expanded: false }))
  237. const storedByID = new Map(stored.map((project) => [project.id, project] as const))
  238. return props
  239. .load(search, current.signal)
  240. .then((result) =>
  241. result.data
  242. .map(normalizeSessionInfo)
  243. .filter((session) => !session.time.archived)
  244. .map((session) => {
  245. const project =
  246. projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
  247. return {
  248. id: `session:${props.server}:${session.id}`,
  249. type: "session" as const,
  250. title: session.title || props.untitled(),
  251. description: project ? displayName(project) : getFilename(session.directory),
  252. category: props.category(),
  253. directory: session.directory,
  254. sessionID: session.id,
  255. server: props.server,
  256. project,
  257. updated: session.time.updated,
  258. }
  259. }),
  260. )
  261. .catch(() => [] as CommandPaletteEntry[])
  262. }
  263. }