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, on, 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" function pathToFileUrl(filepath: string): string { const encodedPath = filepath .split("/") .map((segment) => encodeURIComponent(segment)) .join("/") return `file://${encodedPath}` } 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)) } 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() 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) } return out.toSorted((a, b) => { if (a.type !== b.type) { return a.type === "directory" ? -1 : 1 } return a.name.localeCompare(b.name) }) }) 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", pathToFileUrl(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-warning-active)" : 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-warning-active)" 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-warning-active)" 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)}>
) }}
) }