import { useFile } from "@/context/file" import { FileIcon } from "@opencode-ai/ui/file-icon" import "@opencode-ai/ui/v2/file-tree-v2.css" import { createEffect, createMemo, createSignal, For, Show, splitProps, type ComponentProps, type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" import type { FileNode } from "@opencode-ai/sdk/v2" import { Icon } from "@opencode-ai/ui/v2/icon" import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2, normalizeFileTreeV2Path, type FileTreeV2Node, } from "@/components/file-tree-v2-model" import { virtualScrollElement } from "@/components/virtual-scroll-element" export type { Kind } from "@/components/file-tree" const INDENT_STEP = 16 function rowPaddingStart(level: number, type: FileNode["type"]) { if (type === "directory") return 8 + level * INDENT_STEP if (level === 0) return 8 return 8 + level * INDENT_STEP - INDENT_STEP } function guideLineStart(level: number) { return rowPaddingStart(level, "directory") + 8 } export const kindLabel = (kind: Kind) => { if (kind === "add") return "A" if (kind === "del") return "D" return "M" } export const kindChange = (kind: Kind) => { if (kind === "add") return "added" if (kind === "del") return "deleted" return "modified" } const FileTreeNodeV2 = ( p: ParentProps & ComponentProps<"div"> & ComponentProps<"button"> & { node: FileNode level: number active?: string draggable: boolean kinds?: ReadonlyMap as?: "div" | "button" }, ) => { const [local, rest] = splitProps(p, [ "node", "level", "active", "draggable", "kinds", "as", "children", "class", "classList", ]) const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path)) 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 || local.node.type !== "file") return null return ( {kindLabel(value)} ) })()} ) } function GuideLines(props: { level: number }) { return ( {(_, index) =>
} ) } export default function FileTreeV2(props: { active?: string allowed?: readonly string[] kinds?: ReadonlyMap draggable?: boolean onFileClick?: (file: FileNode) => void onFileDoubleClick?: (file: FileNode) => void }) { const file = useFile() const live = () => props.allowed === undefined const draggable = () => props.draggable ?? true const active = () => normalizeFileTreeV2Path(props.active ?? "") const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? []))) const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live() const rows = createMemo(() => { if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded) return flattenFileTreeV2(model()!, expanded) }) const [root, setRoot] = createSignal() const [focused, setFocused] = createSignal() const virtualizer = createVirtualizer({ get count() { return rows().length }, getScrollElement: () => virtualScrollElement(root()), initialRect: { width: 0, height: 600 }, estimateSize: () => 28, gap: 2, overscan: 10, get getItemKey() { const current = rows() return (index: number) => current[index]?.node.path ?? index }, rangeExtractor: (range) => { const indexes = defaultRangeExtractor(range) const path = focused() const index = path ? rows().findIndex((row) => row.node.path === path) : -1 if (index < 0 || indexes.includes(index)) return indexes return [...indexes, index].sort((a, b) => a - b) }, }) createEffect(() => { if (!live()) return void file.tree.list("") }) // Only scroll when the active path changes (or first appears in the tree). // Do not re-scroll when expand/collapse reshuffles `rows()`. let scrolledActive: string | undefined createEffect(() => { const path = active() if (!path) { scrolledActive = undefined return } const index = rows().findIndex((row) => row.node.path === path) if (index < 0) return if (scrolledActive === path) return scrolledActive = path queueMicrotask(() => { const next = rows().findIndex((row) => row.node.path === path) if (next < 0) return if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return virtualizer.scrollToIndex(next, { align: "auto" }) }) }) const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => { action?.({ ...node, path: node.originalPath, absolute: node.originalPath, }) } const toggleDirectory = (path: string, originalPath: string) => { if (expanded(path)) { file.tree.collapse(originalPath) return } file.tree.expand(originalPath, live() ? undefined : { list: false }) } const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const))) const virtualItemByKey = createMemo( () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), ) const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key)) return (
{(key) => ( {(item) => (
{(row) => ( setFocused(row().node.path)} onBlur={() => setFocused(undefined)} onClick={() => selectFile(row().node, props.onFileClick)} onDblClick={() => selectFile(row().node, props.onFileDoubleClick)} > 0}>
} > setFocused(row().node.path)} onBlur={() => setFocused(undefined)} aria-expanded={expanded(row().node.path)} onClick={() => toggleDirectory(row().node.path, row().node.originalPath)} >
)}
)}
)}
) }