| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543 |
- 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 { Tooltip } from "@opencode-ai/ui/tooltip"
- 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<string>
- dirs: Set<string>
- }
- 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<string> }
- 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-warning-active)"
- }
- 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-warning-active)"
- }
- const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
- 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<string, Kind>
- marks?: Set<string>
- 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 (
- <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 === local.active,
- ...(local.classList ?? {}),
- [local.class ?? ""]: !!local.class,
- [local.nodeClass ?? ""]: !!local.nodeClass,
- }}
- style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
- draggable={local.draggable}
- onDragStart={(event: DragEvent) => {
- 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}
- <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 value = kind()
- if (!value) return null
- if (local.node.type === "file") {
- return (
- <span class="shrink-0 w-4 text-center text-12-medium" style={kindTextColor(value)}>
- {kindLabel(value)}
- </span>
- )
- }
- return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={kindDotColor(value)} />
- })()}
- </Dynamic>
- )
- }
- const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => {
- if (!props.enabled) return props.children
- const parts = props.node.path.split("/")
- const leaf = parts[parts.length - 1] ?? props.node.path
- const head = parts.slice(0, -1).join("/")
- const prefix = head ? `${head}/` : ""
- const label =
- props.kind === "add"
- ? "Additions"
- : props.kind === "del"
- ? "Deletions"
- : props.kind === "mix"
- ? "Modifications"
- : undefined
- return (
- <Tooltip
- 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}>
- {(text) => (
- <>
- <span class="mx-1 font-bold text-text-invert-strong">•</span>
- <span class="shrink-0 text-text-invert-strong">{text()}</span>
- </>
- )}
- </Show>
- <Show when={props.node.type === "directory" && props.node.ignored}>
- <>
- <span class="mx-1 font-bold text-text-invert-strong">•</span>
- <span class="shrink-0 text-text-invert-strong">Ignored</span>
- </>
- </Show>
- </div>
- }
- >
- {props.children}
- </Tooltip>
- )
- }
- 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>
- _chain?: readonly string[]
- }) {
- const file = useFile()
- const level = props.level ?? 0
- const draggable = () => props.draggable ?? true
- const tooltip = () => props.tooltip ?? 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<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 root = props.path
- if (!(file.tree.state(root)?.expanded ?? false)) return out
- const seen = new Set<string>()
- 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 (
- <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 kind = () => visibleKind(node, kinds(), marks())
- 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>
- <FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
- <FileTreeNode
- node={node}
- level={level}
- active={props.active}
- nodeClass={props.nodeClass}
- draggable={draggable()}
- kinds={kinds()}
- marks={marks()}
- >
- <div class="size-4 flex items-center justify-center text-icon-weak">
- <Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
- </div>
- </FileTreeNode>
- </FileTreeNodeTooltip>
- </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`}
- />
- <Show
- when={level < MAX_DEPTH && !chain.includes(key(node.path))}
- fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
- >
- <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()}
- _chain={chain}
- />
- </Show>
- </Collapsible.Content>
- </Collapsible>
- </Match>
- <Match when={node.type === "file"}>
- <FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
- <FileTreeNode
- node={node}
- level={level}
- active={props.active}
- nodeClass={props.nodeClass}
- draggable={draggable()}
- kinds={kinds()}
- marks={marks()}
- as="button"
- type="button"
- onClick={() => props.onFileClick?.(node)}
- >
- <div class="w-4 shrink-0" />
- <FileIcon node={node} class="text-icon-weak size-4" />
- </FileTreeNode>
- </FileTreeNodeTooltip>
- </Match>
- </Switch>
- )
- }}
- </For>
- </div>
- )
- }
|