Files
opencode/packages/app/src/components/file-tree.tsx
2026-01-26 11:07:52 -06:00

209 lines
6.8 KiB
TypeScript

import { useFile } from "@/context/file"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import {
createEffect,
createMemo,
For,
Match,
splitProps,
Switch,
untrack,
type ComponentProps,
type ParentProps,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2"
export default function FileTree(props: {
path: string
class?: string
nodeClass?: string
level?: number
allowed?: readonly string[]
modified?: readonly string[]
draggable?: boolean
tooltip?: boolean
onFileClick?: (file: FileNode) => void
}) {
const file = useFile()
const level = props.level ?? 0
const draggable = () => props.draggable ?? true
const tooltip = () => props.tooltip ?? true
const filter = createMemo(() => {
const allowed = props.allowed
if (!allowed) return
const files = new Set(allowed)
const dirs = new Set<string>()
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(() => {
const modified = props.modified
if (!modified || modified.length === 0) return
return new Set(modified)
})
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(() => {
void file.tree.list(props.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 (
<Dynamic
component={local.as ?? "div"}
classList={{
"w-full min-w-0 flex items-center justify-start gap-x-2 rounded-md px-2 py-1 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
[props.nodeClass ?? ""]: !!props.nodeClass,
}}
style={`padding-left: ${8 + level * 12}px`}
draggable={draggable()}
onDragStart={(e: DragEvent) => {
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}
<span
classList={{
"flex-1 min-w-0 text-12-regular whitespace-nowrap truncate": true,
"text-text-weaker": local.node.ignored,
"text-text-weak": !local.node.ignored,
}}
>
{local.node.name}
</span>
{local.node.type === "file" && marks()?.has(local.node.path) ? (
<div class="shrink-0 size-1.5 rounded-full bg-surface-warning-strong" />
) : null}
</Dynamic>
)
}
return (
<div class={`flex flex-col ${props.class ?? ""}`}>
<For each={nodes()}>
{(node) => {
const expanded = () => file.tree.state(node.path)?.expanded ?? false
const Wrapper = (p: ParentProps) => {
if (!tooltip()) return p.children
return (
<Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right" class="w-full">
{p.children}
</Tooltip>
)
}
return (
<Switch>
<Match when={node.type === "directory"}>
<Collapsible
variant="ghost"
class="w-full"
forceMount={false}
open={expanded()}
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
>
<Collapsible.Trigger>
<Wrapper>
<Node node={node}>
<Collapsible.Arrow class="text-icon-weak ml-1" />
<FileIcon node={node} expanded={expanded()} class="text-icon-weak -ml-1 size-4" />
</Node>
</Wrapper>
</Collapsible.Trigger>
<Collapsible.Content>
<FileTree
path={node.path}
level={level + 1}
allowed={props.allowed}
modified={props.modified}
draggable={props.draggable}
tooltip={props.tooltip}
onFileClick={props.onFileClick}
/>
</Collapsible.Content>
</Collapsible>
</Match>
<Match when={node.type === "file"}>
<Wrapper>
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
<div class="w-4 shrink-0" />
<FileIcon node={node} class="text-icon-weak size-4" />
</Node>
</Wrapper>
</Match>
</Switch>
)
}}
</For>
</div>
)
}