file-tree-v2.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. import { useFile } from "@/context/file"
  2. import { FileIcon } from "@kirincode-ai/ui/file-icon"
  3. import "@kirincode-ai/ui/v2/file-tree-v2.css"
  4. import {
  5. createEffect,
  6. createMemo,
  7. createSignal,
  8. For,
  9. Show,
  10. splitProps,
  11. type ComponentProps,
  12. type ParentProps,
  13. } from "solid-js"
  14. import { Dynamic } from "solid-js/web"
  15. import type { FileNode } from "@kirincode-ai/sdk/v2"
  16. import { Icon } from "@kirincode-ai/ui/v2/icon"
  17. import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
  18. import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
  19. import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
  20. import { virtualScrollElement } from "@/components/virtual-scroll-element"
  21. export type { Kind } from "@/components/file-tree"
  22. const INDENT_STEP = 16
  23. function rowPaddingLeft(level: number, type: FileNode["type"]) {
  24. if (type === "directory") return 8 + level * INDENT_STEP
  25. if (level === 0) return 8
  26. return 8 + level * INDENT_STEP - INDENT_STEP
  27. }
  28. function guideLineLeft(level: number) {
  29. return rowPaddingLeft(level, "directory") + 8
  30. }
  31. export const kindLabel = (kind: Kind) => {
  32. if (kind === "add") return "A"
  33. if (kind === "del") return "D"
  34. return ""
  35. }
  36. export const kindChange = (kind: Kind) => {
  37. if (kind === "add") return "added"
  38. if (kind === "del") return "deleted"
  39. return "modified"
  40. }
  41. const FileTreeNodeV2 = (
  42. p: ParentProps &
  43. ComponentProps<"div"> &
  44. ComponentProps<"button"> & {
  45. node: FileNode
  46. level: number
  47. active?: string
  48. draggable: boolean
  49. kinds?: ReadonlyMap<string, Kind>
  50. as?: "div" | "button"
  51. },
  52. ) => {
  53. const [local, rest] = splitProps(p, [
  54. "node",
  55. "level",
  56. "active",
  57. "draggable",
  58. "kinds",
  59. "as",
  60. "children",
  61. "class",
  62. "classList",
  63. ])
  64. const kind = () => local.kinds?.get(local.node.path)
  65. return (
  66. <Dynamic
  67. component={local.as ?? "div"}
  68. data-slot="file-tree-v2-row"
  69. data-path={local.node.path}
  70. data-selected={local.node.path === local.active ? "" : undefined}
  71. data-ignored={local.node.ignored ? "" : undefined}
  72. classList={{
  73. ...local.classList,
  74. [local.class ?? ""]: !!local.class,
  75. }}
  76. style={`padding-left: ${rowPaddingLeft(local.level, local.node.type)}px`}
  77. draggable={local.draggable}
  78. onDragStart={(event: DragEvent) => {
  79. if (!local.draggable) return
  80. event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
  81. event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
  82. if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
  83. withFileDragImage(event)
  84. }}
  85. {...rest}
  86. >
  87. {local.children}
  88. <span class="flex-1 min-w-0 text-12-medium whitespace-nowrap truncate">{local.node.name}</span>
  89. {(() => {
  90. const value = kind()
  91. if (!value || local.node.type !== "file") return null
  92. return (
  93. <span data-slot="file-tree-v2-change" data-change={kindChange(value)}>
  94. {kindLabel(value)}
  95. </span>
  96. )
  97. })()}
  98. </Dynamic>
  99. )
  100. }
  101. function GuideLines(props: { level: number }) {
  102. return (
  103. <For each={Array.from({ length: props.level })}>
  104. {(_, index) => (
  105. <div
  106. class="absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 group-hover/file-tree-v2:opacity-50"
  107. style={`left: ${guideLineLeft(index())}px`}
  108. />
  109. )}
  110. </For>
  111. )
  112. }
  113. export default function FileTreeV2(props: {
  114. active?: string
  115. allowed?: readonly string[]
  116. kinds?: ReadonlyMap<string, Kind>
  117. draggable?: boolean
  118. onFileClick?: (file: FileNode) => void
  119. }) {
  120. const file = useFile()
  121. const draggable = () => props.draggable ?? true
  122. const active = () => normalizeFileTreeV2Path(props.active ?? "")
  123. const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? []))
  124. const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true))
  125. const [root, setRoot] = createSignal<HTMLDivElement>()
  126. const [focused, setFocused] = createSignal<string>()
  127. const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
  128. get count() {
  129. return rows().length
  130. },
  131. getScrollElement: () => virtualScrollElement(root()),
  132. initialRect: { width: 0, height: 600 },
  133. estimateSize: () => 28,
  134. gap: 2,
  135. overscan: 10,
  136. get getItemKey() {
  137. const current = rows()
  138. return (index: number) => current[index]?.node.path ?? index
  139. },
  140. rangeExtractor: (range) => {
  141. const indexes = defaultRangeExtractor(range)
  142. const path = focused()
  143. const index = path ? rows().findIndex((row) => row.node.path === path) : -1
  144. if (index < 0 || indexes.includes(index)) return indexes
  145. return [...indexes, index].sort((a, b) => a - b)
  146. },
  147. })
  148. createEffect(() => {
  149. const path = active()
  150. if (!path) return
  151. const index = rows().findIndex((row) => row.node.path === path)
  152. if (index < 0) return
  153. queueMicrotask(() => {
  154. if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
  155. virtualizer.scrollToIndex(index, { align: "auto" })
  156. })
  157. })
  158. const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
  159. const virtualItemByKey = createMemo(
  160. () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
  161. )
  162. const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
  163. return (
  164. <div
  165. ref={setRoot}
  166. data-component="file-tree-v2"
  167. data-total-rows={model().total}
  168. class="group/file-tree-v2"
  169. style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
  170. >
  171. <For each={virtualRowKeys()}>
  172. {(key) => (
  173. <Show when={virtualItemByKey().get(key)}>
  174. {(item) => (
  175. <div
  176. style={{
  177. position: "absolute",
  178. top: "0",
  179. left: "0",
  180. width: "100%",
  181. height: `${item().size}px`,
  182. transform: `translateY(${item().start}px)`,
  183. }}
  184. >
  185. <Show when={rowByKey().get(key as string)}>
  186. {(row) => (
  187. <Show
  188. when={row().node.type === "directory"}
  189. fallback={
  190. <FileTreeNodeV2
  191. node={row().node}
  192. level={row().level}
  193. active={active()}
  194. draggable={draggable()}
  195. kinds={props.kinds}
  196. as="button"
  197. type="button"
  198. class="relative"
  199. onFocus={() => setFocused(row().node.path)}
  200. onBlur={() => setFocused(undefined)}
  201. onClick={() =>
  202. props.onFileClick?.({
  203. ...row().node,
  204. path: row().node.originalPath,
  205. absolute: row().node.originalPath,
  206. })
  207. }
  208. >
  209. <GuideLines level={row().level} />
  210. <Show when={row().level > 0}>
  211. <div class="w-4 shrink-0" />
  212. </Show>
  213. <span class="filetree-iconpair size-4">
  214. <FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
  215. <FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
  216. </span>
  217. </FileTreeNodeV2>
  218. }
  219. >
  220. <FileTreeNodeV2
  221. node={row().node}
  222. level={row().level}
  223. active={active()}
  224. draggable={draggable()}
  225. kinds={props.kinds}
  226. as="button"
  227. type="button"
  228. class="relative"
  229. onFocus={() => setFocused(row().node.path)}
  230. onBlur={() => setFocused(undefined)}
  231. aria-expanded={file.tree.state(row().node.path)?.expanded ?? true}
  232. onClick={() =>
  233. file.tree.state(row().node.path)?.expanded === false
  234. ? file.tree.expand(row().node.path, { list: false })
  235. : file.tree.collapse(row().node.path)
  236. }
  237. >
  238. <GuideLines level={row().level} />
  239. <div
  240. data-slot="file-tree-v2-chevron"
  241. data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""}
  242. class="size-4 flex items-center justify-center"
  243. >
  244. <Icon name="chevron-down" />
  245. </div>
  246. </FileTreeNodeV2>
  247. </Show>
  248. )}
  249. </Show>
  250. </div>
  251. )}
  252. </Show>
  253. )}
  254. </For>
  255. </div>
  256. )
  257. }