file-tree.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. import { useFile } from "@/context/file"
  2. import { encodeFilePath } from "@/context/file/path"
  3. import { Collapsible } from "@opencode-ai/ui/collapsible"
  4. import { FileIcon } from "@opencode-ai/ui/file-icon"
  5. import { Icon } from "@opencode-ai/ui/icon"
  6. import { Tooltip } from "@opencode-ai/ui/tooltip"
  7. import {
  8. createEffect,
  9. createMemo,
  10. For,
  11. Match,
  12. on,
  13. Show,
  14. splitProps,
  15. Switch,
  16. untrack,
  17. type ComponentProps,
  18. type JSXElement,
  19. type ParentProps,
  20. } from "solid-js"
  21. import { Dynamic } from "solid-js/web"
  22. import type { FileNode } from "@opencode-ai/sdk/v2"
  23. const MAX_DEPTH = 128
  24. function pathToFileUrl(filepath: string): string {
  25. return `file://${encodeFilePath(filepath)}`
  26. }
  27. type Kind = "add" | "del" | "mix"
  28. type Filter = {
  29. files: Set<string>
  30. dirs: Set<string>
  31. }
  32. export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) {
  33. if (input.level !== 0) return false
  34. if (input.dir?.loaded) return false
  35. if (input.dir?.loading) return false
  36. return true
  37. }
  38. export function shouldListExpanded(input: {
  39. level: number
  40. dir?: { expanded?: boolean; loaded?: boolean; loading?: boolean }
  41. }) {
  42. if (input.level === 0) return false
  43. if (!input.dir?.expanded) return false
  44. if (input.dir.loaded) return false
  45. if (input.dir.loading) return false
  46. return true
  47. }
  48. export function dirsToExpand(input: {
  49. level: number
  50. filter?: { dirs: Set<string> }
  51. expanded: (dir: string) => boolean
  52. }) {
  53. if (input.level !== 0) return []
  54. if (!input.filter) return []
  55. return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
  56. }
  57. const kindLabel = (kind: Kind) => {
  58. if (kind === "add") return "A"
  59. if (kind === "del") return "D"
  60. return "M"
  61. }
  62. const kindTextColor = (kind: Kind) => {
  63. if (kind === "add") return "color: var(--icon-diff-add-base)"
  64. if (kind === "del") return "color: var(--icon-diff-delete-base)"
  65. return "color: var(--icon-warning-active)"
  66. }
  67. const kindDotColor = (kind: Kind) => {
  68. if (kind === "add") return "background-color: var(--icon-diff-add-base)"
  69. if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
  70. return "background-color: var(--icon-warning-active)"
  71. }
  72. const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
  73. const kind = kinds?.get(node.path)
  74. if (!kind) return
  75. if (!marks?.has(node.path)) return
  76. return kind
  77. }
  78. const buildDragImage = (target: HTMLElement) => {
  79. const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg")
  80. const text = target.querySelector("span")
  81. if (!icon || !text) return
  82. const image = document.createElement("div")
  83. image.className =
  84. "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"
  85. image.style.position = "absolute"
  86. image.style.top = "-1000px"
  87. image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
  88. return image
  89. }
  90. const withFileDragImage = (event: DragEvent) => {
  91. const image = buildDragImage(event.currentTarget as HTMLElement)
  92. if (!image) return
  93. document.body.appendChild(image)
  94. event.dataTransfer?.setDragImage(image, 0, 12)
  95. setTimeout(() => document.body.removeChild(image), 0)
  96. }
  97. const FileTreeNode = (
  98. p: ParentProps &
  99. ComponentProps<"div"> &
  100. ComponentProps<"button"> & {
  101. node: FileNode
  102. level: number
  103. active?: string
  104. nodeClass?: string
  105. draggable: boolean
  106. kinds?: ReadonlyMap<string, Kind>
  107. marks?: Set<string>
  108. as?: "div" | "button"
  109. },
  110. ) => {
  111. const [local, rest] = splitProps(p, [
  112. "node",
  113. "level",
  114. "active",
  115. "nodeClass",
  116. "draggable",
  117. "kinds",
  118. "marks",
  119. "as",
  120. "children",
  121. "class",
  122. "classList",
  123. ])
  124. const kind = () => visibleKind(local.node, local.kinds, local.marks)
  125. const active = () => !!kind() && !local.node.ignored
  126. const color = () => {
  127. const value = kind()
  128. if (!value) return
  129. return kindTextColor(value)
  130. }
  131. return (
  132. <Dynamic
  133. component={local.as ?? "div"}
  134. classList={{
  135. "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,
  136. "bg-surface-base-active": local.node.path === local.active,
  137. ...(local.classList ?? {}),
  138. [local.class ?? ""]: !!local.class,
  139. [local.nodeClass ?? ""]: !!local.nodeClass,
  140. }}
  141. style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
  142. draggable={local.draggable}
  143. onDragStart={(event: DragEvent) => {
  144. if (!local.draggable) return
  145. event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
  146. event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
  147. if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
  148. withFileDragImage(event)
  149. }}
  150. {...rest}
  151. >
  152. {local.children}
  153. <span
  154. classList={{
  155. "flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
  156. "text-text-weaker": local.node.ignored,
  157. "text-text-weak": !local.node.ignored && !active(),
  158. }}
  159. style={active() ? color() : undefined}
  160. >
  161. {local.node.name}
  162. </span>
  163. {(() => {
  164. const value = kind()
  165. if (!value) return null
  166. if (local.node.type === "file") {
  167. return (
  168. <span class="shrink-0 w-4 text-center text-12-medium" style={kindTextColor(value)}>
  169. {kindLabel(value)}
  170. </span>
  171. )
  172. }
  173. return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={kindDotColor(value)} />
  174. })()}
  175. </Dynamic>
  176. )
  177. }
  178. const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => {
  179. if (!props.enabled) return props.children
  180. const parts = props.node.path.split("/")
  181. const leaf = parts[parts.length - 1] ?? props.node.path
  182. const head = parts.slice(0, -1).join("/")
  183. const prefix = head ? `${head}/` : ""
  184. const label =
  185. props.kind === "add"
  186. ? "Additions"
  187. : props.kind === "del"
  188. ? "Deletions"
  189. : props.kind === "mix"
  190. ? "Modifications"
  191. : undefined
  192. return (
  193. <Tooltip
  194. openDelay={2000}
  195. placement="bottom-start"
  196. class="w-full"
  197. contentStyle={{ "max-width": "480px", width: "fit-content" }}
  198. value={
  199. <div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
  200. <span
  201. class="min-w-0 truncate text-text-invert-base"
  202. style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
  203. >
  204. {prefix}
  205. </span>
  206. <span class="shrink-0 text-text-invert-strong">{leaf}</span>
  207. <Show when={label}>
  208. {(text) => (
  209. <>
  210. <span class="mx-1 font-bold text-text-invert-strong">•</span>
  211. <span class="shrink-0 text-text-invert-strong">{text()}</span>
  212. </>
  213. )}
  214. </Show>
  215. <Show when={props.node.type === "directory" && props.node.ignored}>
  216. <>
  217. <span class="mx-1 font-bold text-text-invert-strong">•</span>
  218. <span class="shrink-0 text-text-invert-strong">Ignored</span>
  219. </>
  220. </Show>
  221. </div>
  222. }
  223. >
  224. {props.children}
  225. </Tooltip>
  226. )
  227. }
  228. export default function FileTree(props: {
  229. path: string
  230. class?: string
  231. nodeClass?: string
  232. active?: string
  233. level?: number
  234. allowed?: readonly string[]
  235. modified?: readonly string[]
  236. kinds?: ReadonlyMap<string, Kind>
  237. draggable?: boolean
  238. tooltip?: boolean
  239. onFileClick?: (file: FileNode) => void
  240. _filter?: Filter
  241. _marks?: Set<string>
  242. _deeps?: Map<string, number>
  243. _kinds?: ReadonlyMap<string, Kind>
  244. _chain?: readonly string[]
  245. }) {
  246. const file = useFile()
  247. const level = props.level ?? 0
  248. const draggable = () => props.draggable ?? true
  249. const tooltip = () => props.tooltip ?? true
  250. const key = (p: string) =>
  251. file
  252. .normalize(p)
  253. .replace(/[\\/]+$/, "")
  254. .replaceAll("\\", "/")
  255. const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
  256. const filter = createMemo(() => {
  257. if (props._filter) return props._filter
  258. const allowed = props.allowed
  259. if (!allowed) return
  260. const files = new Set(allowed)
  261. const dirs = new Set<string>()
  262. for (const item of allowed) {
  263. const parts = item.split("/")
  264. const parents = parts.slice(0, -1)
  265. for (const [idx] of parents.entries()) {
  266. const dir = parents.slice(0, idx + 1).join("/")
  267. if (dir) dirs.add(dir)
  268. }
  269. }
  270. return { files, dirs }
  271. })
  272. const marks = createMemo(() => {
  273. if (props._marks) return props._marks
  274. const out = new Set<string>()
  275. for (const item of props.modified ?? []) out.add(item)
  276. for (const item of props.kinds?.keys() ?? []) out.add(item)
  277. if (out.size === 0) return
  278. return out
  279. })
  280. const kinds = createMemo(() => {
  281. if (props._kinds) return props._kinds
  282. return props.kinds
  283. })
  284. const deeps = createMemo(() => {
  285. if (props._deeps) return props._deeps
  286. const out = new Map<string, number>()
  287. const root = props.path
  288. if (!(file.tree.state(root)?.expanded ?? false)) return out
  289. const seen = new Set<string>()
  290. const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
  291. const push = (dir: string, lvl: number) => {
  292. const id = key(dir)
  293. if (seen.has(id)) return
  294. seen.add(id)
  295. const kids = file.tree
  296. .children(dir)
  297. .filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
  298. .map((node) => node.path)
  299. stack.push({ dir, lvl, i: 0, kids, max: lvl })
  300. }
  301. push(root, level - 1)
  302. while (stack.length > 0) {
  303. const top = stack[stack.length - 1]!
  304. if (top.i < top.kids.length) {
  305. const next = top.kids[top.i]!
  306. top.i++
  307. push(next, top.lvl + 1)
  308. continue
  309. }
  310. out.set(top.dir, top.max)
  311. stack.pop()
  312. const parent = stack[stack.length - 1]
  313. if (!parent) continue
  314. parent.max = Math.max(parent.max, top.max)
  315. }
  316. return out
  317. })
  318. createEffect(() => {
  319. const current = filter()
  320. const dirs = dirsToExpand({
  321. level,
  322. filter: current,
  323. expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
  324. })
  325. for (const dir of dirs) file.tree.expand(dir)
  326. })
  327. createEffect(
  328. on(
  329. () => props.path,
  330. (path) => {
  331. const dir = untrack(() => file.tree.state(path))
  332. if (!shouldListRoot({ level, dir })) return
  333. void file.tree.list(path)
  334. },
  335. { defer: false },
  336. ),
  337. )
  338. createEffect(() => {
  339. const dir = file.tree.state(props.path)
  340. if (!shouldListExpanded({ level, dir })) return
  341. void file.tree.list(props.path)
  342. })
  343. const nodes = createMemo(() => {
  344. const nodes = file.tree.children(props.path)
  345. const current = filter()
  346. if (!current) return nodes
  347. const parent = (path: string) => {
  348. const idx = path.lastIndexOf("/")
  349. if (idx === -1) return ""
  350. return path.slice(0, idx)
  351. }
  352. const leaf = (path: string) => {
  353. const idx = path.lastIndexOf("/")
  354. return idx === -1 ? path : path.slice(idx + 1)
  355. }
  356. const out = nodes.filter((node) => {
  357. if (node.type === "file") return current.files.has(node.path)
  358. return current.dirs.has(node.path)
  359. })
  360. const seen = new Set(out.map((node) => node.path))
  361. for (const dir of current.dirs) {
  362. if (parent(dir) !== props.path) continue
  363. if (seen.has(dir)) continue
  364. out.push({
  365. name: leaf(dir),
  366. path: dir,
  367. absolute: dir,
  368. type: "directory",
  369. ignored: false,
  370. })
  371. seen.add(dir)
  372. }
  373. for (const item of current.files) {
  374. if (parent(item) !== props.path) continue
  375. if (seen.has(item)) continue
  376. out.push({
  377. name: leaf(item),
  378. path: item,
  379. absolute: item,
  380. type: "file",
  381. ignored: false,
  382. })
  383. seen.add(item)
  384. }
  385. out.sort((a, b) => {
  386. if (a.type !== b.type) {
  387. return a.type === "directory" ? -1 : 1
  388. }
  389. return a.name.localeCompare(b.name)
  390. })
  391. return out
  392. })
  393. return (
  394. <div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
  395. <For each={nodes()}>
  396. {(node) => {
  397. const expanded = () => file.tree.state(node.path)?.expanded ?? false
  398. const deep = () => deeps().get(node.path) ?? -1
  399. const kind = () => visibleKind(node, kinds(), marks())
  400. return (
  401. <Switch>
  402. <Match when={node.type === "directory"}>
  403. <Collapsible
  404. variant="ghost"
  405. class="w-full"
  406. data-scope="filetree"
  407. forceMount={false}
  408. open={expanded()}
  409. onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
  410. >
  411. <Collapsible.Trigger>
  412. <FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
  413. <FileTreeNode
  414. node={node}
  415. level={level}
  416. active={props.active}
  417. nodeClass={props.nodeClass}
  418. draggable={draggable()}
  419. kinds={kinds()}
  420. marks={marks()}
  421. >
  422. <div class="size-4 flex items-center justify-center text-icon-weak">
  423. <Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
  424. </div>
  425. </FileTreeNode>
  426. </FileTreeNodeTooltip>
  427. </Collapsible.Trigger>
  428. <Collapsible.Content class="relative pt-0.5">
  429. <div
  430. classList={{
  431. "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,
  432. "group-hover/filetree:opacity-100": expanded() && deep() === level,
  433. "group-hover/filetree:opacity-50": !(expanded() && deep() === level),
  434. }}
  435. style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
  436. />
  437. <Show
  438. when={level < MAX_DEPTH && !chain.includes(key(node.path))}
  439. fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
  440. >
  441. <FileTree
  442. path={node.path}
  443. level={level + 1}
  444. allowed={props.allowed}
  445. modified={props.modified}
  446. kinds={props.kinds}
  447. active={props.active}
  448. draggable={props.draggable}
  449. tooltip={props.tooltip}
  450. onFileClick={props.onFileClick}
  451. _filter={filter()}
  452. _marks={marks()}
  453. _deeps={deeps()}
  454. _kinds={kinds()}
  455. _chain={chain}
  456. />
  457. </Show>
  458. </Collapsible.Content>
  459. </Collapsible>
  460. </Match>
  461. <Match when={node.type === "file"}>
  462. <FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
  463. <FileTreeNode
  464. node={node}
  465. level={level}
  466. active={props.active}
  467. nodeClass={props.nodeClass}
  468. draggable={draggable()}
  469. kinds={kinds()}
  470. marks={marks()}
  471. as="button"
  472. type="button"
  473. onClick={() => props.onFileClick?.(node)}
  474. >
  475. <div class="w-4 shrink-0" />
  476. <FileIcon node={node} class="text-icon-weak size-4" />
  477. </FileTreeNode>
  478. </FileTreeNodeTooltip>
  479. </Match>
  480. </Switch>
  481. )
  482. }}
  483. </For>
  484. </div>
  485. )
  486. }