file-tree.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import { useFile } from "@/context/file"
  2. import { Collapsible } from "@opencode-ai/ui/collapsible"
  3. import { FileIcon } from "@opencode-ai/ui/file-icon"
  4. import { Icon } from "@opencode-ai/ui/icon"
  5. import { Tooltip } from "@opencode-ai/ui/tooltip"
  6. import {
  7. createEffect,
  8. createMemo,
  9. For,
  10. Match,
  11. Show,
  12. splitProps,
  13. Switch,
  14. untrack,
  15. type ComponentProps,
  16. type ParentProps,
  17. } from "solid-js"
  18. import { Dynamic } from "solid-js/web"
  19. import type { FileNode } from "@opencode-ai/sdk/v2"
  20. type Kind = "add" | "del" | "mix"
  21. type Filter = {
  22. files: Set<string>
  23. dirs: Set<string>
  24. }
  25. export default function FileTree(props: {
  26. path: string
  27. class?: string
  28. nodeClass?: string
  29. active?: string
  30. level?: number
  31. allowed?: readonly string[]
  32. modified?: readonly string[]
  33. kinds?: ReadonlyMap<string, Kind>
  34. draggable?: boolean
  35. tooltip?: boolean
  36. onFileClick?: (file: FileNode) => void
  37. _filter?: Filter
  38. _marks?: Set<string>
  39. _deeps?: Map<string, number>
  40. _kinds?: ReadonlyMap<string, Kind>
  41. }) {
  42. const file = useFile()
  43. const level = props.level ?? 0
  44. const draggable = () => props.draggable ?? true
  45. const tooltip = () => props.tooltip ?? true
  46. const filter = createMemo(() => {
  47. if (props._filter) return props._filter
  48. const allowed = props.allowed
  49. if (!allowed) return
  50. const files = new Set(allowed)
  51. const dirs = new Set<string>()
  52. for (const item of allowed) {
  53. const parts = item.split("/")
  54. const parents = parts.slice(0, -1)
  55. for (const [idx] of parents.entries()) {
  56. const dir = parents.slice(0, idx + 1).join("/")
  57. if (dir) dirs.add(dir)
  58. }
  59. }
  60. return { files, dirs }
  61. })
  62. const marks = createMemo(() => {
  63. if (props._marks) return props._marks
  64. const out = new Set<string>()
  65. for (const item of props.modified ?? []) out.add(item)
  66. for (const item of props.kinds?.keys() ?? []) out.add(item)
  67. if (out.size === 0) return
  68. return out
  69. })
  70. const kinds = createMemo(() => {
  71. if (props._kinds) return props._kinds
  72. return props.kinds
  73. })
  74. const deeps = createMemo(() => {
  75. if (props._deeps) return props._deeps
  76. const out = new Map<string, number>()
  77. const visit = (dir: string, lvl: number): number => {
  78. const expanded = file.tree.state(dir)?.expanded ?? false
  79. if (!expanded) return -1
  80. const nodes = file.tree.children(dir)
  81. const max = nodes.reduce((max, node) => {
  82. if (node.type !== "directory") return max
  83. const open = file.tree.state(node.path)?.expanded ?? false
  84. if (!open) return max
  85. return Math.max(max, visit(node.path, lvl + 1))
  86. }, lvl)
  87. out.set(dir, max)
  88. return max
  89. }
  90. visit(props.path, level - 1)
  91. return out
  92. })
  93. createEffect(() => {
  94. const current = filter()
  95. if (!current) return
  96. if (level !== 0) return
  97. for (const dir of current.dirs) {
  98. const expanded = untrack(() => file.tree.state(dir)?.expanded) ?? false
  99. if (expanded) continue
  100. file.tree.expand(dir)
  101. }
  102. })
  103. createEffect(() => {
  104. const path = props.path
  105. untrack(() => void file.tree.list(path))
  106. })
  107. const nodes = createMemo(() => {
  108. const nodes = file.tree.children(props.path)
  109. const current = filter()
  110. if (!current) return nodes
  111. return nodes.filter((node) => {
  112. if (node.type === "file") return current.files.has(node.path)
  113. return current.dirs.has(node.path)
  114. })
  115. })
  116. const Node = (
  117. p: ParentProps &
  118. ComponentProps<"div"> &
  119. ComponentProps<"button"> & {
  120. node: FileNode
  121. as?: "div" | "button"
  122. },
  123. ) => {
  124. const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
  125. return (
  126. <Dynamic
  127. component={local.as ?? "div"}
  128. classList={{
  129. "w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
  130. "bg-surface-base-active": local.node.path === props.active,
  131. ...(local.classList ?? {}),
  132. [local.class ?? ""]: !!local.class,
  133. [props.nodeClass ?? ""]: !!props.nodeClass,
  134. }}
  135. style={`padding-left: ${Math.max(0, 8 + level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
  136. draggable={draggable()}
  137. onDragStart={(e: DragEvent) => {
  138. if (!draggable()) return
  139. e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
  140. e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`)
  141. if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
  142. const dragImage = document.createElement("div")
  143. dragImage.className =
  144. "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
  145. dragImage.style.position = "absolute"
  146. dragImage.style.top = "-1000px"
  147. const icon =
  148. (e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ??
  149. (e.currentTarget as HTMLElement).querySelector("svg")
  150. const text = (e.currentTarget as HTMLElement).querySelector("span")
  151. if (icon && text) {
  152. dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
  153. }
  154. document.body.appendChild(dragImage)
  155. e.dataTransfer?.setDragImage(dragImage, 0, 12)
  156. setTimeout(() => document.body.removeChild(dragImage), 0)
  157. }}
  158. {...rest}
  159. >
  160. {local.children}
  161. {(() => {
  162. const kind = kinds()?.get(local.node.path)
  163. const marked = marks()?.has(local.node.path) ?? false
  164. const active = !!kind && marked && !local.node.ignored
  165. const color =
  166. kind === "add"
  167. ? "color: var(--icon-diff-add-base)"
  168. : kind === "del"
  169. ? "color: var(--icon-diff-delete-base)"
  170. : kind === "mix"
  171. ? "color: var(--icon-diff-modified-base)"
  172. : undefined
  173. return (
  174. <span
  175. classList={{
  176. "flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
  177. "text-text-weaker": local.node.ignored,
  178. "text-text-weak": !local.node.ignored && !active,
  179. }}
  180. style={active ? color : undefined}
  181. >
  182. {local.node.name}
  183. </span>
  184. )
  185. })()}
  186. {(() => {
  187. const kind = kinds()?.get(local.node.path)
  188. if (!kind) return null
  189. if (!marks()?.has(local.node.path)) return null
  190. if (local.node.type === "file") {
  191. const text = kind === "add" ? "A" : kind === "del" ? "D" : "M"
  192. const color =
  193. kind === "add"
  194. ? "color: var(--icon-diff-add-base)"
  195. : kind === "del"
  196. ? "color: var(--icon-diff-delete-base)"
  197. : "color: var(--icon-diff-modified-base)"
  198. return (
  199. <span class="shrink-0 w-4 text-center text-12-medium" style={color}>
  200. {text}
  201. </span>
  202. )
  203. }
  204. if (local.node.type === "directory") {
  205. const color =
  206. kind === "add"
  207. ? "background-color: var(--icon-diff-add-base)"
  208. : kind === "del"
  209. ? "background-color: var(--icon-diff-delete-base)"
  210. : "background-color: var(--icon-diff-modified-base)"
  211. return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={color} />
  212. }
  213. return null
  214. })()}
  215. </Dynamic>
  216. )
  217. }
  218. return (
  219. <div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
  220. <For each={nodes()}>
  221. {(node) => {
  222. const expanded = () => file.tree.state(node.path)?.expanded ?? false
  223. const deep = () => deeps().get(node.path) ?? -1
  224. const Wrapper = (p: ParentProps) => {
  225. if (!tooltip()) return p.children
  226. const parts = node.path.split("/")
  227. const leaf = parts[parts.length - 1] ?? node.path
  228. const head = parts.slice(0, -1).join("/")
  229. const prefix = head ? `${head}/` : ""
  230. const kind = () => kinds()?.get(node.path)
  231. const label = () => {
  232. const k = kind()
  233. if (!k) return
  234. if (k === "add") return "Additions"
  235. if (k === "del") return "Deletions"
  236. return "Modifications"
  237. }
  238. const ignored = () => node.type === "directory" && node.ignored
  239. return (
  240. <Tooltip
  241. forceMount={false}
  242. openDelay={2000}
  243. placement="bottom-start"
  244. class="w-full"
  245. contentStyle={{ "max-width": "480px", width: "fit-content" }}
  246. value={
  247. <div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
  248. <span
  249. class="min-w-0 truncate text-text-invert-base"
  250. style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
  251. >
  252. {prefix}
  253. </span>
  254. <span class="shrink-0 text-text-invert-strong">{leaf}</span>
  255. <Show when={label()}>
  256. {(t: () => string) => (
  257. <>
  258. <span class="mx-1 font-bold text-text-invert-strong">•</span>
  259. <span class="shrink-0 text-text-invert-strong">{t()}</span>
  260. </>
  261. )}
  262. </Show>
  263. <Show when={ignored()}>
  264. <>
  265. <span class="mx-1 font-bold text-text-invert-strong">•</span>
  266. <span class="shrink-0 text-text-invert-strong">Ignored</span>
  267. </>
  268. </Show>
  269. </div>
  270. }
  271. >
  272. {p.children}
  273. </Tooltip>
  274. )
  275. }
  276. return (
  277. <Switch>
  278. <Match when={node.type === "directory"}>
  279. <Collapsible
  280. variant="ghost"
  281. class="w-full"
  282. data-scope="filetree"
  283. forceMount={false}
  284. open={expanded()}
  285. onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
  286. >
  287. <Collapsible.Trigger>
  288. <Wrapper>
  289. <Node node={node}>
  290. <div class="size-4 flex items-center justify-center text-icon-weak">
  291. <Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
  292. </div>
  293. </Node>
  294. </Wrapper>
  295. </Collapsible.Trigger>
  296. <Collapsible.Content class="relative pt-0.5">
  297. <div
  298. classList={{
  299. "absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 transition-opacity duration-150 ease-out motion-reduce:transition-none": true,
  300. "group-hover/filetree:opacity-100": expanded() && deep() === level,
  301. "group-hover/filetree:opacity-50": !(expanded() && deep() === level),
  302. }}
  303. style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
  304. />
  305. <FileTree
  306. path={node.path}
  307. level={level + 1}
  308. allowed={props.allowed}
  309. modified={props.modified}
  310. kinds={props.kinds}
  311. active={props.active}
  312. draggable={props.draggable}
  313. tooltip={props.tooltip}
  314. onFileClick={props.onFileClick}
  315. _filter={filter()}
  316. _marks={marks()}
  317. _deeps={deeps()}
  318. _kinds={kinds()}
  319. />
  320. </Collapsible.Content>
  321. </Collapsible>
  322. </Match>
  323. <Match when={node.type === "file"}>
  324. <Wrapper>
  325. <Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
  326. <div class="w-4 shrink-0" />
  327. <FileIcon node={node} class="text-icon-weak size-4" />
  328. </Node>
  329. </Wrapper>
  330. </Match>
  331. </Switch>
  332. )
  333. }}
  334. </For>
  335. </div>
  336. )
  337. }