| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- 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<string>
- dirs: Set<string>
- }
- export default function FileTree(props: {
- path: string
- class?: string
- nodeClass?: string
- active?: string
- level?: number
- allowed?: readonly string[]
- modified?: readonly string[]
- kinds?: ReadonlyMap<string, Kind>
- draggable?: boolean
- tooltip?: boolean
- onFileClick?: (file: FileNode) => void
- _filter?: Filter
- _marks?: Set<string>
- _deeps?: Map<string, number>
- _kinds?: ReadonlyMap<string, Kind>
- }) {
- 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<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(() => {
- if (props._marks) return props._marks
- const out = new Set<string>()
- 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<string, number>()
- 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 (
- <Dynamic
- component={local.as ?? "div"}
- classList={{
- "w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
- "bg-surface-base-active": local.node.path === props.active,
- ...(local.classList ?? {}),
- [local.class ?? ""]: !!local.class,
- [props.nodeClass ?? ""]: !!props.nodeClass,
- }}
- style={`padding-left: ${Math.max(0, 8 + level * 12 - (local.node.type === "file" ? 24 : 4))}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}
- {(() => {
- 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 (
- <span
- classList={{
- "flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
- "text-text-weaker": local.node.ignored,
- "text-text-weak": !local.node.ignored && !active,
- }}
- style={active ? color : undefined}
- >
- {local.node.name}
- </span>
- )
- })()}
- {(() => {
- 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 (
- <span class="shrink-0 w-4 text-center text-12-medium" style={color}>
- {text}
- </span>
- )
- }
- 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 <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={color} />
- }
- return null
- })()}
- </Dynamic>
- )
- }
- return (
- <div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
- <For each={nodes()}>
- {(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 (
- <Tooltip
- forceMount={false}
- openDelay={2000}
- placement="bottom-start"
- class="w-full"
- contentStyle={{ "max-width": "480px", width: "fit-content" }}
- value={
- <div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
- <span
- class="min-w-0 truncate text-text-invert-base"
- style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
- >
- {prefix}
- </span>
- <span class="shrink-0 text-text-invert-strong">{leaf}</span>
- <Show when={label()}>
- {(t: () => string) => (
- <>
- <span class="mx-1 font-bold text-text-invert-strong">•</span>
- <span class="shrink-0 text-text-invert-strong">{t()}</span>
- </>
- )}
- </Show>
- <Show when={ignored()}>
- <>
- <span class="mx-1 font-bold text-text-invert-strong">•</span>
- <span class="shrink-0 text-text-invert-strong">Ignored</span>
- </>
- </Show>
- </div>
- }
- >
- {p.children}
- </Tooltip>
- )
- }
- return (
- <Switch>
- <Match when={node.type === "directory"}>
- <Collapsible
- variant="ghost"
- class="w-full"
- data-scope="filetree"
- forceMount={false}
- open={expanded()}
- onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
- >
- <Collapsible.Trigger>
- <Wrapper>
- <Node node={node}>
- <div class="size-4 flex items-center justify-center text-icon-weak">
- <Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
- </div>
- </Node>
- </Wrapper>
- </Collapsible.Trigger>
- <Collapsible.Content class="relative pt-0.5">
- <div
- classList={{
- "absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 transition-opacity duration-150 ease-out motion-reduce:transition-none": true,
- "group-hover/filetree:opacity-100": expanded() && deep() === level,
- "group-hover/filetree:opacity-50": !(expanded() && deep() === level),
- }}
- style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
- />
- <FileTree
- path={node.path}
- level={level + 1}
- allowed={props.allowed}
- modified={props.modified}
- kinds={props.kinds}
- active={props.active}
- draggable={props.draggable}
- tooltip={props.tooltip}
- onFileClick={props.onFileClick}
- _filter={filter()}
- _marks={marks()}
- _deeps={deeps()}
- _kinds={kinds()}
- />
- </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>
- )
- }
|