import { useFile } from "@/context/file" import { encodeFilePath } from "@/context/file/path" import { Collapsible } from "@opencode-ai/ui/collapsible" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { createEffect, createMemo, For, Match, on, Show, splitProps, Switch, untrack, type ComponentProps, type JSXElement, type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" import type { FileNode } from "@opencode-ai/sdk/v2" const MAX_DEPTH = 128 function pathToFileUrl(filepath: string): string { return `file://${encodeFilePath(filepath)}` } type Kind = "add" | "del" | "mix" type Filter = { files: Set dirs: Set } export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) { if (input.level !== 0) return false if (input.dir?.loaded) return false if (input.dir?.loading) return false return true } export function shouldListExpanded(input: { level: number dir?: { expanded?: boolean; loaded?: boolean; loading?: boolean } }) { if (input.level === 0) return false if (!input.dir?.expanded) return false if (input.dir.loaded) return false if (input.dir.loading) return false return true } export function dirsToExpand(input: { level: number filter?: { dirs: Set } expanded: (dir: string) => boolean }) { if (input.level !== 0) return [] if (!input.filter) return [] return [...input.filter.dirs].filter((dir) => !input.expanded(dir)) } const kindLabel = (kind: Kind) => { if (kind === "add") return "A" if (kind === "del") return "D" return "M" } const kindTextColor = (kind: Kind) => { if (kind === "add") return "color: var(--icon-diff-add-base)" if (kind === "del") return "color: var(--icon-diff-delete-base)" return "color: var(--icon-diff-modified-base)" } const kindDotColor = (kind: Kind) => { if (kind === "add") return "background-color: var(--icon-diff-add-base)" if (kind === "del") return "background-color: var(--icon-diff-delete-base)" return "background-color: var(--icon-diff-modified-base)" } const visibleKind = (node: FileNode, kinds?: ReadonlyMap, marks?: Set) => { const kind = kinds?.get(node.path) if (!kind) return if (!marks?.has(node.path)) return return kind } const buildDragImage = (target: HTMLElement) => { const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg") const text = target.querySelector("span") if (!icon || !text) return const image = document.createElement("div") image.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" image.style.position = "absolute" image.style.top = "-1000px" image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML return image } const withFileDragImage = (event: DragEvent) => { const image = buildDragImage(event.currentTarget as HTMLElement) if (!image) return document.body.appendChild(image) event.dataTransfer?.setDragImage(image, 0, 12) setTimeout(() => document.body.removeChild(image), 0) } const FileTreeNode = ( p: ParentProps & ComponentProps<"div"> & ComponentProps<"button"> & { node: FileNode level: number active?: string nodeClass?: string draggable: boolean kinds?: ReadonlyMap marks?: Set as?: "div" | "button" }, ) => { const [local, rest] = splitProps(p, [ "node", "level", "active", "nodeClass", "draggable", "kinds", "marks", "as", "children", "class", "classList", ]) const kind = () => visibleKind(local.node, local.kinds, local.marks) const active = () => !!kind() && !local.node.ignored const color = () => { const value = kind() if (!value) return return kindTextColor(value) } return ( { if (!local.draggable) return event.dataTransfer?.setData("text/plain", `file:${local.node.path}`) event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy" withFileDragImage(event) }} {...rest} > {local.children} {local.node.name} {(() => { const value = kind() if (!value) return null if (local.node.type === "file") { return ( {kindLabel(value)} ) } return
})()} ) } 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 onFileClick?: (file: FileNode) => void _filter?: Filter _marks?: Set _deeps?: Map _kinds?: ReadonlyMap _chain?: readonly string[] }) { const file = useFile() const level = props.level ?? 0 const draggable = () => props.draggable ?? true const key = (p: string) => file .normalize(p) .replace(/[\\/]+$/, "") .replaceAll("\\", "/") const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)] 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 root = props.path if (!(file.tree.state(root)?.expanded ?? false)) return out const seen = new Set() const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = [] const push = (dir: string, lvl: number) => { const id = key(dir) if (seen.has(id)) return seen.add(id) const kids = file.tree .children(dir) .filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false)) .map((node) => node.path) stack.push({ dir, lvl, i: 0, kids, max: lvl }) } push(root, level - 1) while (stack.length > 0) { const top = stack[stack.length - 1]! if (top.i < top.kids.length) { const next = top.kids[top.i]! top.i++ push(next, top.lvl + 1) continue } out.set(top.dir, top.max) stack.pop() const parent = stack[stack.length - 1] if (!parent) continue parent.max = Math.max(parent.max, top.max) } return out }) createEffect(() => { const current = filter() const dirs = dirsToExpand({ level, filter: current, expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false, }) for (const dir of dirs) file.tree.expand(dir) }) createEffect( on( () => props.path, (path) => { const dir = untrack(() => file.tree.state(path)) if (!shouldListRoot({ level, dir })) return void file.tree.list(path) }, { defer: false }, ), ) createEffect(() => { const dir = file.tree.state(props.path) if (!shouldListExpanded({ level, dir })) return void file.tree.list(props.path) }) const nodes = createMemo(() => { const nodes = file.tree.children(props.path) const current = filter() if (!current) return nodes const parent = (path: string) => { const idx = path.lastIndexOf("/") if (idx === -1) return "" return path.slice(0, idx) } const leaf = (path: string) => { const idx = path.lastIndexOf("/") return idx === -1 ? path : path.slice(idx + 1) } const out = nodes.filter((node) => { if (node.type === "file") return current.files.has(node.path) return current.dirs.has(node.path) }) const seen = new Set(out.map((node) => node.path)) for (const dir of current.dirs) { if (parent(dir) !== props.path) continue if (seen.has(dir)) continue out.push({ name: leaf(dir), path: dir, absolute: dir, type: "directory", ignored: false, }) seen.add(dir) } for (const item of current.files) { if (parent(item) !== props.path) continue if (seen.has(item)) continue out.push({ name: leaf(item), path: item, absolute: item, type: "file", ignored: false, }) seen.add(item) } out.sort((a, b) => { if (a.type !== b.type) { return a.type === "directory" ? -1 : 1 } return a.name.localeCompare(b.name) }) return out }) return (
{(node) => { const expanded = () => file.tree.state(node.path)?.expanded ?? false const deep = () => deeps().get(node.path) ?? -1 const kind = () => visibleKind(node, kinds(), marks()) const active = () => !!kind() && !node.ignored return ( (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} >
...
} >
props.onFileClick?.(node)} >
) }}
) }