dialog-select-file.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. import { useDialog } from "@opencode-ai/ui/context/dialog"
  2. import { Dialog } from "@opencode-ai/ui/dialog"
  3. import { FileIcon } from "@opencode-ai/ui/file-icon"
  4. import { Icon } from "@opencode-ai/ui/icon"
  5. import { Keybind } from "@opencode-ai/ui/keybind"
  6. import { List } from "@opencode-ai/ui/list"
  7. import { base64Encode } from "@opencode-ai/shared/util/encode"
  8. import { getDirectory, getFilename } from "@opencode-ai/shared/util/path"
  9. import { useNavigate } from "@solidjs/router"
  10. import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
  11. import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
  12. import { useGlobalSDK } from "@/context/global-sdk"
  13. import { useGlobalSync } from "@/context/global-sync"
  14. import { useLayout } from "@/context/layout"
  15. import { useFile } from "@/context/file"
  16. import { useLanguage } from "@/context/language"
  17. import { useSessionLayout } from "@/pages/session/session-layout"
  18. import { createSessionTabs } from "@/pages/session/helpers"
  19. import { decode64 } from "@/utils/base64"
  20. import { getRelativeTime } from "@/utils/time"
  21. type EntryType = "command" | "file" | "session"
  22. type Entry = {
  23. id: string
  24. type: EntryType
  25. title: string
  26. description?: string
  27. keybind?: string
  28. category: string
  29. option?: CommandOption
  30. path?: string
  31. directory?: string
  32. sessionID?: string
  33. archived?: number
  34. updated?: number
  35. }
  36. type DialogSelectFileMode = "all" | "files"
  37. const ENTRY_LIMIT = 5
  38. const COMMON_COMMAND_IDS = [
  39. "session.new",
  40. "workspace.new",
  41. "session.previous",
  42. "session.next",
  43. "terminal.toggle",
  44. "review.toggle",
  45. ] as const
  46. const uniqueEntries = (items: Entry[]) => {
  47. const seen = new Set<string>()
  48. const out: Entry[] = []
  49. for (const item of items) {
  50. if (seen.has(item.id)) continue
  51. seen.add(item.id)
  52. out.push(item)
  53. }
  54. return out
  55. }
  56. const createCommandEntry = (option: CommandOption, category: string): Entry => ({
  57. id: "command:" + option.id,
  58. type: "command",
  59. title: option.title,
  60. description: option.description,
  61. keybind: option.keybind,
  62. category,
  63. option,
  64. })
  65. const createFileEntry = (path: string, category: string): Entry => ({
  66. id: "file:" + path,
  67. type: "file",
  68. title: path,
  69. category,
  70. path,
  71. })
  72. const createSessionEntry = (
  73. input: {
  74. directory: string
  75. id: string
  76. title: string
  77. description: string
  78. archived?: number
  79. updated?: number
  80. },
  81. category: string,
  82. ): Entry => ({
  83. id: `session:${input.directory}:${input.id}`,
  84. type: "session",
  85. title: input.title,
  86. description: input.description,
  87. category,
  88. directory: input.directory,
  89. sessionID: input.id,
  90. archived: input.archived,
  91. updated: input.updated,
  92. })
  93. function createCommandEntries(props: {
  94. filesOnly: () => boolean
  95. command: ReturnType<typeof useCommand>
  96. language: ReturnType<typeof useLanguage>
  97. }) {
  98. const allowed = createMemo(() => {
  99. if (props.filesOnly()) return []
  100. return props.command.options.filter(
  101. (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
  102. )
  103. })
  104. const list = createMemo(() => {
  105. const category = props.language.t("palette.group.commands")
  106. return allowed().map((option) => createCommandEntry(option, category))
  107. })
  108. const picks = createMemo(() => {
  109. const all = allowed()
  110. const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
  111. const picked = all.filter((option) => order.has(option.id))
  112. const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
  113. const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
  114. const category = props.language.t("palette.group.commands")
  115. return sorted.map((option) => createCommandEntry(option, category))
  116. })
  117. return { allowed, list, picks }
  118. }
  119. function createFileEntries(props: {
  120. file: ReturnType<typeof useFile>
  121. tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
  122. language: ReturnType<typeof useLanguage>
  123. }) {
  124. const tabState = createSessionTabs({
  125. tabs: props.tabs,
  126. pathFromTab: props.file.pathFromTab,
  127. normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
  128. })
  129. const recent = createMemo(() => {
  130. const all = tabState.openedTabs()
  131. const active = tabState.activeFileTab()
  132. const order = active ? [active, ...all.filter((item) => item !== active)] : all
  133. const seen = new Set<string>()
  134. const category = props.language.t("palette.group.files")
  135. const items: Entry[] = []
  136. for (const item of order) {
  137. const path = props.file.pathFromTab(item)
  138. if (!path) continue
  139. if (seen.has(path)) continue
  140. seen.add(path)
  141. items.push(createFileEntry(path, category))
  142. }
  143. return items.slice(0, ENTRY_LIMIT)
  144. })
  145. const root = createMemo(() => {
  146. const category = props.language.t("palette.group.files")
  147. const nodes = props.file.tree.children("")
  148. const paths = nodes
  149. .filter((node) => node.type === "file")
  150. .map((node) => node.path)
  151. .sort((a, b) => a.localeCompare(b))
  152. return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
  153. })
  154. return { recent, root }
  155. }
  156. function createSessionEntries(props: {
  157. workspaces: () => string[]
  158. label: (directory: string) => string
  159. globalSDK: ReturnType<typeof useGlobalSDK>
  160. language: ReturnType<typeof useLanguage>
  161. }) {
  162. const state: {
  163. token: number
  164. inflight: Promise<Entry[]> | undefined
  165. cached: Entry[] | undefined
  166. } = {
  167. token: 0,
  168. inflight: undefined,
  169. cached: undefined,
  170. }
  171. const sessions = (text: string) => {
  172. const query = text.trim()
  173. if (!query) {
  174. state.token += 1
  175. state.inflight = undefined
  176. state.cached = undefined
  177. return [] as Entry[]
  178. }
  179. if (state.cached) return state.cached
  180. if (state.inflight) return state.inflight
  181. const current = state.token
  182. const dirs = props.workspaces()
  183. if (dirs.length === 0) return [] as Entry[]
  184. state.inflight = Promise.all(
  185. dirs.map((directory) => {
  186. const description = props.label(directory)
  187. return props.globalSDK.client.session
  188. .list({ directory, roots: true })
  189. .then((x) =>
  190. (x.data ?? [])
  191. .filter((s) => !!s?.id)
  192. .map((s) => ({
  193. id: s.id,
  194. title: s.title ?? props.language.t("command.session.new"),
  195. description,
  196. directory,
  197. archived: s.time?.archived,
  198. updated: s.time?.updated,
  199. })),
  200. )
  201. .catch(
  202. () =>
  203. [] as {
  204. id: string
  205. title: string
  206. description: string
  207. directory: string
  208. archived?: number
  209. updated?: number
  210. }[],
  211. )
  212. }),
  213. )
  214. .then((results) => {
  215. if (state.token !== current) return [] as Entry[]
  216. const seen = new Set<string>()
  217. const category = props.language.t("command.category.session")
  218. const next = results
  219. .flat()
  220. .filter((item) => {
  221. const key = `${item.directory}:${item.id}`
  222. if (seen.has(key)) return false
  223. seen.add(key)
  224. return true
  225. })
  226. .map((item) => createSessionEntry(item, category))
  227. state.cached = next
  228. return next
  229. })
  230. .catch(() => [] as Entry[])
  231. .finally(() => {
  232. state.inflight = undefined
  233. })
  234. return state.inflight
  235. }
  236. return { sessions }
  237. }
  238. export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
  239. const command = useCommand()
  240. const language = useLanguage()
  241. const layout = useLayout()
  242. const file = useFile()
  243. const dialog = useDialog()
  244. const navigate = useNavigate()
  245. const globalSDK = useGlobalSDK()
  246. const globalSync = useGlobalSync()
  247. const { params, tabs, view } = useSessionLayout()
  248. const filesOnly = () => props.mode === "files"
  249. const state = { cleanup: undefined as (() => void) | void, committed: false }
  250. const [grouped, setGrouped] = createSignal(false)
  251. const commandEntries = createCommandEntries({ filesOnly, command, language })
  252. const fileEntries = createFileEntries({ file, tabs, language })
  253. const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
  254. const project = createMemo(() => {
  255. const directory = projectDirectory()
  256. if (!directory) return
  257. return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
  258. })
  259. const workspaces = createMemo(() => {
  260. const directory = projectDirectory()
  261. const current = project()
  262. if (!current) return directory ? [directory] : []
  263. const dirs = [current.worktree, ...(current.sandboxes ?? [])]
  264. if (directory && !dirs.includes(directory)) return [...dirs, directory]
  265. return dirs
  266. })
  267. const homedir = createMemo(() => globalSync.data.path.home)
  268. const label = (directory: string) => {
  269. const current = project()
  270. const kind =
  271. current && directory === current.worktree
  272. ? language.t("workspace.type.local")
  273. : language.t("workspace.type.sandbox")
  274. const [store] = globalSync.child(directory, { bootstrap: false })
  275. const home = homedir()
  276. const path = home ? directory.replace(home, "~") : directory
  277. const name = store.vcs?.branch ?? getFilename(directory)
  278. return `${kind} : ${name || path}`
  279. }
  280. const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language })
  281. const items = async (text: string) => {
  282. const query = text.trim()
  283. setGrouped(query.length > 0)
  284. if (!query && filesOnly()) {
  285. const loaded = file.tree.state("")?.loaded
  286. const pending = loaded ? Promise.resolve() : file.tree.list("")
  287. const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
  288. if (loaded || next.length > 0) {
  289. void pending
  290. return next
  291. }
  292. await pending
  293. return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
  294. }
  295. if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
  296. if (filesOnly()) {
  297. const files = await file.searchFiles(query)
  298. const category = language.t("palette.group.files")
  299. return files.map((path) => createFileEntry(path, category))
  300. }
  301. const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
  302. const category = language.t("palette.group.files")
  303. const entries = files.map((path) => createFileEntry(path, category))
  304. return [...commandEntries.list(), ...nextSessions, ...entries]
  305. }
  306. const handleMove = (item: Entry | undefined) => {
  307. state.cleanup?.()
  308. if (!item) return
  309. if (item.type !== "command") return
  310. state.cleanup = item.option?.onHighlight?.()
  311. }
  312. const open = (path: string) => {
  313. const value = file.tab(path)
  314. void tabs().open(value)
  315. void file.load(path)
  316. if (!view().reviewPanel.opened()) view().reviewPanel.open()
  317. layout.fileTree.setTab("all")
  318. props.onOpenFile?.(path)
  319. tabs().setActive(value)
  320. }
  321. const handleSelect = (item: Entry | undefined) => {
  322. if (!item) return
  323. state.committed = true
  324. state.cleanup = undefined
  325. dialog.close()
  326. if (item.type === "command") {
  327. item.option?.onSelect?.("palette")
  328. return
  329. }
  330. if (item.type === "session") {
  331. if (!item.directory || !item.sessionID) return
  332. navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
  333. return
  334. }
  335. if (!item.path) return
  336. open(item.path)
  337. }
  338. onCleanup(() => {
  339. if (state.committed) return
  340. state.cleanup?.()
  341. })
  342. return (
  343. <Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
  344. <List
  345. search={{
  346. placeholder: filesOnly()
  347. ? language.t("session.header.searchFiles")
  348. : language.t("palette.search.placeholder"),
  349. autofocus: true,
  350. hideIcon: true,
  351. }}
  352. emptyMessage={language.t("palette.empty")}
  353. loadingMessage={language.t("common.loading")}
  354. items={items}
  355. key={(item) => item.id}
  356. filterKeys={["title", "description", "category"]}
  357. groupBy={grouped() ? (item) => item.category : () => ""}
  358. onMove={handleMove}
  359. onSelect={handleSelect}
  360. >
  361. {(item) => (
  362. <Switch
  363. fallback={
  364. <div class="w-full flex items-center justify-between rounded-md pl-1">
  365. <div class="flex items-center gap-x-3 grow min-w-0">
  366. <FileIcon node={{ path: item.path ?? "", type: "file" }} class="shrink-0 size-4" />
  367. <div class="flex items-center text-14-regular">
  368. <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
  369. {getDirectory(item.path ?? "")}
  370. </span>
  371. <span class="text-text-strong whitespace-nowrap">{getFilename(item.path ?? "")}</span>
  372. </div>
  373. </div>
  374. </div>
  375. }
  376. >
  377. <Match when={item.type === "command"}>
  378. <div class="w-full flex items-center justify-between gap-4">
  379. <div class="flex items-center gap-2 min-w-0">
  380. <span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
  381. <Show when={item.description}>
  382. <span class="text-14-regular text-text-weak truncate">{item.description}</span>
  383. </Show>
  384. </div>
  385. <Show when={item.keybind}>
  386. <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
  387. </Show>
  388. </div>
  389. </Match>
  390. <Match when={item.type === "session"}>
  391. <div class="w-full flex items-center justify-between rounded-md pl-1">
  392. <div class="flex items-center gap-x-3 grow min-w-0">
  393. <Icon name="bubble-5" size="small" class="shrink-0 text-icon-weak" />
  394. <div class="flex items-center gap-2 min-w-0">
  395. <span
  396. class="text-14-regular text-text-strong truncate"
  397. classList={{ "opacity-70": !!item.archived }}
  398. >
  399. {item.title}
  400. </span>
  401. <Show when={item.description}>
  402. <span
  403. class="text-14-regular text-text-weak truncate"
  404. classList={{ "opacity-70": !!item.archived }}
  405. >
  406. {item.description}
  407. </span>
  408. </Show>
  409. </div>
  410. </div>
  411. <Show when={item.updated}>
  412. <span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
  413. {getRelativeTime(new Date(item.updated!).toISOString(), language.t)}
  414. </span>
  415. </Show>
  416. </div>
  417. </Match>
  418. </Switch>
  419. )}
  420. </List>
  421. </Dialog>
  422. )
  423. }