opencode/packages/app/src/components/file-tree-v2.tsx
Aiden Cline 9e0d3976e1
chore: merge dev into v2 (#35591)
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: runvip <164729189+runvip@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
2026-07-06 16:05:29 -05:00

268 lines
9.4 KiB
TypeScript

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, normalizeFileTreeV2Path } 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 rowPaddingLeft(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 guideLineLeft(level: number) {
return rowPaddingLeft(level, "directory") + 8
}
export const kindLabel = (kind: Kind) => {
if (kind === "add") return "A"
if (kind === "del") return "D"
return ""
}
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<string, Kind>
as?: "div" | "button"
},
) => {
const [local, rest] = splitProps(p, [
"node",
"level",
"active",
"draggable",
"kinds",
"as",
"children",
"class",
"classList",
])
const kind = () => local.kinds?.get(local.node.path)
return (
<Dynamic
component={local.as ?? "div"}
data-slot="file-tree-v2-row"
data-path={local.node.path}
data-selected={local.node.path === local.active ? "" : undefined}
data-ignored={local.node.ignored ? "" : undefined}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
style={`padding-left: ${rowPaddingLeft(local.level, local.node.type)}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 class="flex-1 min-w-0 text-12-medium whitespace-nowrap truncate">{local.node.name}</span>
{(() => {
const value = kind()
if (!value || local.node.type !== "file") return null
return (
<span data-slot="file-tree-v2-change" data-change={kindChange(value)}>
{kindLabel(value)}
</span>
)
})()}
</Dynamic>
)
}
function GuideLines(props: { level: number }) {
return (
<For each={Array.from({ length: props.level })}>
{(_, index) => (
<div
class="absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 group-hover/file-tree-v2:opacity-50"
style={`left: ${guideLineLeft(index())}px`}
/>
)}
</For>
)
}
export default function FileTreeV2(props: {
active?: string
allowed?: readonly string[]
kinds?: ReadonlyMap<string, Kind>
draggable?: boolean
onFileClick?: (file: FileNode) => void
}) {
const file = useFile()
const draggable = () => props.draggable ?? true
const active = () => normalizeFileTreeV2Path(props.active ?? "")
const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? []))
const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true))
const [root, setRoot] = createSignal<HTMLDivElement>()
const [focused, setFocused] = createSignal<string>()
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
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(() => {
const path = active()
if (!path) return
const index = rows().findIndex((row) => row.node.path === path)
if (index < 0) return
queueMicrotask(() => {
if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
virtualizer.scrollToIndex(index, { align: "auto" })
})
})
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 (
<div
ref={setRoot}
data-component="file-tree-v2"
data-total-rows={model().total}
class="group/file-tree-v2"
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
>
<For each={virtualRowKeys()}>
{(key) => (
<Show when={virtualItemByKey().get(key)}>
{(item) => (
<div
style={{
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,
}}
>
<Show when={rowByKey().get(key as string)}>
{(row) => (
<Show
when={row().node.type === "directory"}
fallback={
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
kinds={props.kinds}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() =>
props.onFileClick?.({
...row().node,
path: row().node.originalPath,
absolute: row().node.originalPath,
})
}
>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
<div class="w-4 shrink-0" />
</Show>
<span class="filetree-iconpair size-4">
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</FileTreeNodeV2>
}
>
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
kinds={props.kinds}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
aria-expanded={file.tree.state(row().node.path)?.expanded ?? true}
onClick={() =>
file.tree.state(row().node.path)?.expanded === false
? file.tree.expand(row().node.path, { list: false })
: file.tree.collapse(row().node.path)
}
>
<GuideLines level={row().level} />
<div
data-slot="file-tree-v2-chevron"
data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""}
class="size-4 flex items-center justify-center"
>
<Icon name="chevron-down" />
</div>
</FileTreeNodeV2>
</Show>
)}
</Show>
</div>
)}
</Show>
)}
</For>
</div>
)
}