dialog-select-file.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  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 { Keybind } from "@opencode-ai/ui/keybind"
  5. import { List } from "@opencode-ai/ui/list"
  6. import { getDirectory, getFilename } from "@opencode-ai/util/path"
  7. import { useParams } from "@solidjs/router"
  8. import { createMemo, createSignal, onCleanup, Show } from "solid-js"
  9. import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
  10. import { useLayout } from "@/context/layout"
  11. import { useFile } from "@/context/file"
  12. import { useLanguage } from "@/context/language"
  13. type EntryType = "command" | "file"
  14. type Entry = {
  15. id: string
  16. type: EntryType
  17. title: string
  18. description?: string
  19. keybind?: string
  20. category: string
  21. option?: CommandOption
  22. path?: string
  23. }
  24. export function DialogSelectFile() {
  25. const command = useCommand()
  26. const language = useLanguage()
  27. const layout = useLayout()
  28. const file = useFile()
  29. const dialog = useDialog()
  30. const params = useParams()
  31. const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
  32. const tabs = createMemo(() => layout.tabs(sessionKey()))
  33. const view = createMemo(() => layout.view(sessionKey()))
  34. const state = { cleanup: undefined as (() => void) | void, committed: false }
  35. const [grouped, setGrouped] = createSignal(false)
  36. const common = [
  37. "session.new",
  38. "workspace.new",
  39. "session.previous",
  40. "session.next",
  41. "terminal.toggle",
  42. "review.toggle",
  43. ]
  44. const limit = 5
  45. const allowed = createMemo(() =>
  46. command.options.filter(
  47. (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
  48. ),
  49. )
  50. const commandItem = (option: CommandOption): Entry => ({
  51. id: "command:" + option.id,
  52. type: "command",
  53. title: option.title,
  54. description: option.description,
  55. keybind: option.keybind,
  56. category: language.t("palette.group.commands"),
  57. option,
  58. })
  59. const fileItem = (path: string): Entry => ({
  60. id: "file:" + path,
  61. type: "file",
  62. title: path,
  63. category: language.t("palette.group.files"),
  64. path,
  65. })
  66. const list = createMemo(() => allowed().map(commandItem))
  67. const picks = createMemo(() => {
  68. const all = allowed()
  69. const order = new Map(common.map((id, index) => [id, index]))
  70. const picked = all.filter((option) => order.has(option.id))
  71. const base = picked.length ? picked : all.slice(0, limit)
  72. const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
  73. return sorted.map(commandItem)
  74. })
  75. const recent = createMemo(() => {
  76. const all = tabs().all()
  77. const active = tabs().active()
  78. const order = active ? [active, ...all.filter((item) => item !== active)] : all
  79. const seen = new Set<string>()
  80. const items: Entry[] = []
  81. for (const item of order) {
  82. const path = file.pathFromTab(item)
  83. if (!path) continue
  84. if (seen.has(path)) continue
  85. seen.add(path)
  86. items.push(fileItem(path))
  87. }
  88. return items.slice(0, limit)
  89. })
  90. const items = async (filter: string) => {
  91. const query = filter.trim()
  92. setGrouped(query.length > 0)
  93. if (!query) return [...picks(), ...recent()]
  94. const files = await file.searchFiles(query)
  95. const entries = files.map(fileItem)
  96. return [...list(), ...entries]
  97. }
  98. const handleMove = (item: Entry | undefined) => {
  99. state.cleanup?.()
  100. if (!item) return
  101. if (item.type !== "command") return
  102. state.cleanup = item.option?.onHighlight?.()
  103. }
  104. const open = (path: string) => {
  105. const value = file.tab(path)
  106. tabs().open(value)
  107. file.load(path)
  108. view().reviewPanel.open()
  109. }
  110. const handleSelect = (item: Entry | undefined) => {
  111. if (!item) return
  112. state.committed = true
  113. state.cleanup = undefined
  114. dialog.close()
  115. if (item.type === "command") {
  116. item.option?.onSelect?.("palette")
  117. return
  118. }
  119. if (!item.path) return
  120. open(item.path)
  121. }
  122. onCleanup(() => {
  123. if (state.committed) return
  124. state.cleanup?.()
  125. })
  126. return (
  127. <Dialog class="pt-3 pb-0 !max-h-[480px]">
  128. <List
  129. search={{
  130. placeholder: language.t("palette.search.placeholder"),
  131. autofocus: true,
  132. hideIcon: true,
  133. class: "pl-3 pr-2 !mb-0",
  134. }}
  135. emptyMessage={language.t("palette.empty")}
  136. loadingMessage={language.t("common.loading")}
  137. items={items}
  138. key={(item) => item.id}
  139. filterKeys={["title", "description", "category"]}
  140. groupBy={(item) => item.category}
  141. onMove={handleMove}
  142. onSelect={handleSelect}
  143. >
  144. {(item) => (
  145. <Show
  146. when={item.type === "command"}
  147. fallback={
  148. <div class="w-full flex items-center justify-between rounded-md pl-1">
  149. <div class="flex items-center gap-x-3 grow min-w-0">
  150. <FileIcon node={{ path: item.path ?? "", type: "file" }} class="shrink-0 size-4" />
  151. <div class="flex items-center text-14-regular">
  152. <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
  153. {getDirectory(item.path ?? "")}
  154. </span>
  155. <span class="text-text-strong whitespace-nowrap">{getFilename(item.path ?? "")}</span>
  156. </div>
  157. </div>
  158. </div>
  159. }
  160. >
  161. <div class="w-full flex items-center justify-between gap-4 pl-1">
  162. <div class="flex items-center gap-2 min-w-0">
  163. <span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
  164. <Show when={item.description}>
  165. <span class="text-14-regular text-text-weak truncate">{item.description}</span>
  166. </Show>
  167. </div>
  168. <Show when={item.keybind}>
  169. <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
  170. </Show>
  171. </div>
  172. </Show>
  173. )}
  174. </List>
  175. </Dialog>
  176. )
  177. }