import { useFile } from "@/context/file" import { Collapsible } from "@opencode-ai/ui/collapsible" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { Tooltip } from "@opencode-ai/ui/tooltip" import { createEffect, createMemo, For, Match, Show, splitProps, Switch, untrack, type ComponentProps, type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" import type { FileNode } from "@opencode-ai/sdk/v2" type Kind = "add" | "del" | "mix" type Filter = { files: Set dirs: Set } export default function FileTree(props: { path: string class?: string nodeClass?: string active?: string level?: number allowed?: readonly string[] modified?: readonly string[] kinds?: ReadonlyMap draggable?: boolean tooltip?: boolean onFileClick?: (file: FileNode) => void _filter?: Filter _marks?: Set _deeps?: Map _kinds?: ReadonlyMap }) { const file = useFile() const level = props.level ?? 0 const draggable = () => props.draggable ?? true const tooltip = () => props.tooltip ?? true const filter = createMemo(() => { if (props._filter) return props._filter const allowed = props.allowed if (!allowed) return const files = new Set(allowed) const dirs = new Set() for (const item of allowed) { const parts = item.split("/") const parents = parts.slice(0, -1) for (const [idx] of parents.entries()) { const dir = parents.slice(0, idx + 1).join("/") if (dir) dirs.add(dir) } } return { files, dirs } }) const marks = createMemo(() => { if (props._marks) return props._marks const out = new Set() for (const item of props.modified ?? []) out.add(item) for (const item of props.kinds?.keys() ?? []) out.add(item) if (out.size === 0) return return out }) const kinds = createMemo(() => { if (props._kinds) return props._kinds return props.kinds }) const deeps = createMemo(() => { if (props._deeps) return props._deeps const out = new Map() const visit = (dir: string, lvl: number): number => { const expanded = file.tree.state(dir)?.expanded ?? false if (!expanded) return -1 const nodes = file.tree.children(dir) const max = nodes.reduce((max, node) => { if (node.type !== "directory") return max const open = file.tree.state(node.path)?.expanded ?? false if (!open) return max return Math.max(max, visit(node.path, lvl + 1)) }, lvl) out.set(dir, max) return max } visit(props.path, level - 1) return out }) createEffect(() => { const current = filter() if (!current) return if (level !== 0) return for (const dir of current.dirs) { const expanded = untrack(() => file.tree.state(dir)?.expanded) ?? false if (expanded) continue file.tree.expand(dir) } }) createEffect(() => { const path = props.path untrack(() => void file.tree.list(path)) }) const nodes = createMemo(() => { const nodes = file.tree.children(props.path) const current = filter() if (!current) return nodes return nodes.filter((node) => { if (node.type === "file") return current.files.has(node.path) return current.dirs.has(node.path) }) }) const Node = ( p: ParentProps & ComponentProps<"div"> & ComponentProps<"button"> & { node: FileNode as?: "div" | "button" }, ) => { const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"]) return ( { if (!draggable()) return e.dataTransfer?.setData("text/plain", `file:${local.node.path}`) e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`) if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy" const dragImage = document.createElement("div") dragImage.className = "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" dragImage.style.position = "absolute" dragImage.style.top = "-1000px" const icon = (e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ?? (e.currentTarget as HTMLElement).querySelector("svg") const text = (e.currentTarget as HTMLElement).querySelector("span") if (icon && text) { dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML } document.body.appendChild(dragImage) e.dataTransfer?.setDragImage(dragImage, 0, 12) setTimeout(() => document.body.removeChild(dragImage), 0) }} {...rest} > {local.children} {(() => { const kind = kinds()?.get(local.node.path) const marked = marks()?.has(local.node.path) ?? false const active = !!kind && marked && !local.node.ignored const color = kind === "add" ? "color: var(--icon-diff-add-base)" : kind === "del" ? "color: var(--icon-diff-delete-base)" : kind === "mix" ? "color: var(--icon-diff-modified-base)" : undefined return ( {local.node.name} ) })()} {(() => { const kind = kinds()?.get(local.node.path) if (!kind) return null if (!marks()?.has(local.node.path)) return null if (local.node.type === "file") { const text = kind === "add" ? "A" : kind === "del" ? "D" : "M" const color = kind === "add" ? "color: var(--icon-diff-add-base)" : kind === "del" ? "color: var(--icon-diff-delete-base)" : "color: var(--icon-diff-modified-base)" return ( {text} ) } if (local.node.type === "directory") { const color = kind === "add" ? "background-color: var(--icon-diff-add-base)" : kind === "del" ? "background-color: var(--icon-diff-delete-base)" : "background-color: var(--icon-diff-modified-base)" return
} return null })()} ) } return (
{(node) => { const expanded = () => file.tree.state(node.path)?.expanded ?? false const deep = () => deeps().get(node.path) ?? -1 const Wrapper = (p: ParentProps) => { if (!tooltip()) return p.children const parts = node.path.split("/") const leaf = parts[parts.length - 1] ?? node.path const head = parts.slice(0, -1).join("/") const prefix = head ? `${head}/` : "" const kind = () => kinds()?.get(node.path) const label = () => { const k = kind() if (!k) return if (k === "add") return "Additions" if (k === "del") return "Deletions" return "Modifications" } const ignored = () => node.type === "directory" && node.ignored return ( {prefix} {leaf} {(t: () => string) => ( <> {t()} )} <> Ignored
} > {p.children} ) } return ( (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} >
props.onFileClick?.(node)}>
) }}
) }