feat(app): file tree

This commit is contained in:
Adam
2026-01-05 00:25:44 -06:00
committed by adamelmore
parent 7e34d27b77
commit d9eed4c6ca
5 changed files with 364 additions and 93 deletions

View File

@@ -1,111 +1,121 @@
import { useLocal, type LocalFile } from "@/context/local" import { useFile } from "@/context/file"
import { Collapsible } from "@opencode-ai/ui/collapsible" import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { For, Match, Switch, type ComponentProps, type ParentProps } from "solid-js" import { createEffect, For, Match, splitProps, Switch, type ComponentProps, type ParentProps } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2"
export default function FileTree(props: { export default function FileTree(props: {
path: string path: string
class?: string class?: string
nodeClass?: string nodeClass?: string
level?: number level?: number
onFileClick?: (file: LocalFile) => void onFileClick?: (file: FileNode) => void
}) { }) {
const local = useLocal() const file = useFile()
const level = props.level ?? 0 const level = props.level ?? 0
const Node = (p: ParentProps & ComponentProps<"div"> & { node: LocalFile; as?: "div" | "button" }) => ( createEffect(() => {
<Dynamic void file.tree.list(props.path)
component={p.as ?? "div"} })
classList={{
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element": true,
// "bg-background-element": local.file.active()?.path === p.node.path,
[props.nodeClass ?? ""]: !!props.nodeClass,
}}
style={`padding-left: ${level * 10}px`}
draggable={true}
onDragStart={(e: any) => {
const evt = e as globalThis.DragEvent
evt.dataTransfer!.effectAllowed = "copy"
evt.dataTransfer!.setData("text/plain", `file:${p.node.path}`)
// Create custom drag image without margins const Node = (
const dragImage = document.createElement("div") p: ParentProps &
dragImage.className = ComponentProps<"div"> &
"flex items-center gap-x-2 px-2 py-1 bg-background-element rounded-md border border-border-1" ComponentProps<"button"> & {
dragImage.style.position = "absolute" node: FileNode
dragImage.style.top = "-1000px" as?: "div" | "button"
},
// Copy only the icon and text content without padding ) => {
const icon = e.currentTarget.querySelector("svg") const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
const text = e.currentTarget.querySelector("span") return (
if (icon && text) { <Dynamic
dragImage.innerHTML = icon.outerHTML + text.outerHTML component={local.as ?? "div"}
}
document.body.appendChild(dragImage)
evt.dataTransfer!.setDragImage(dragImage, 0, 12)
setTimeout(() => document.body.removeChild(dragImage), 0)
}}
{...p}
>
{p.children}
<span
classList={{ classList={{
"text-xs whitespace-nowrap truncate": true, "w-full flex items-center gap-x-2 rounded-md px-2 py-1 hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
"text-text-muted/40": p.node.ignored, ...(local.classList ?? {}),
"text-text-muted/80": !p.node.ignored, [local.class ?? ""]: !!local.class,
// "!text-text": local.file.active()?.path === p.node.path, [props.nodeClass ?? ""]: !!props.nodeClass,
// "!text-primary": local.file.changed(p.node.path),
}} }}
style={`padding-left: ${8 + level * 12}px`}
draggable={true}
onDragStart={(e: DragEvent) => {
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}
> >
{p.node.name} {local.children}
</span> <span
{/* <Show when={local.file.changed(p.node.path)}> */} classList={{
{/* <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" /> */} "text-12-regular whitespace-nowrap truncate": true,
{/* </Show> */} "text-text-weaker": local.node.ignored,
</Dynamic> "text-text-weak": !local.node.ignored,
) }}
>
{local.node.name}
</span>
</Dynamic>
)
}
return ( return (
<div class={`flex flex-col ${props.class}`}> <div class={`flex flex-col ${props.class ?? ""}`}>
<For each={local.file.children(props.path)}> <For each={file.tree.children(props.path)}>
{(node) => ( {(node) => {
<Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right"> const expanded = () => file.tree.state(node.path)?.expanded ?? false
<Switch> return (
<Match when={node.type === "directory"}> <Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right">
<Collapsible <Switch>
variant="ghost" <Match when={node.type === "directory"}>
class="w-full" <Collapsible
forceMount={false} variant="ghost"
// open={local.file.node(node.path)?.expanded} class="w-full"
onOpenChange={(open) => (open ? local.file.expand(node.path) : local.file.collapse(node.path))} forceMount={false}
> open={expanded()}
<Collapsible.Trigger> onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
<Node node={node}> >
<Collapsible.Arrow class="text-text-muted/60 ml-1" /> <Collapsible.Trigger>
<FileIcon <Node node={node}>
node={node} <Collapsible.Arrow class="text-icon-weak ml-1" />
// expanded={local.file.node(node.path).expanded} <FileIcon node={node} expanded={expanded()} class="text-icon-weak -ml-1 size-4" />
class="text-text-muted/60 -ml-1" </Node>
/> </Collapsible.Trigger>
</Node> <Collapsible.Content>
</Collapsible.Trigger> <FileTree path={node.path} level={level + 1} onFileClick={props.onFileClick} />
<Collapsible.Content> </Collapsible.Content>
<FileTree path={node.path} level={level + 1} onFileClick={props.onFileClick} /> </Collapsible>
</Collapsible.Content> </Match>
</Collapsible> <Match when={node.type === "file"}>
</Match> <Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
<Match when={node.type === "file"}> <div class="w-4 shrink-0" />
<Node node={node} as="button" onClick={() => props.onFileClick?.(node)}> <FileIcon node={node} class="text-icon-weak size-4" />
<div class="w-4 shrink-0" /> </Node>
<FileIcon node={node} class="text-primary" /> </Match>
</Node> </Switch>
</Match> </Tooltip>
</Switch> )
</Tooltip> }}
)}
</For> </For>
</div> </div>
) )

View File

@@ -280,6 +280,32 @@ export function SessionHeader() {
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
</div> </div>
<div class="hidden md:block shrink-0">
<Tooltip value="Toggle file tree" placement="bottom">
<Button
variant="ghost"
class="group/file-tree-toggle size-5 p-0"
onClick={() => {
const opening = !layout.fileTree.opened()
if (opening && !view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.toggle()
}}
aria-label="Toggle file tree"
aria-expanded={layout.fileTree.opened()}
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name="bullet-list"
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</Tooltip>
</div>
<div class="hidden md:block shrink-0"> <div class="hidden md:block shrink-0">
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}> <TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
<Button <Button

View File

@@ -1,7 +1,7 @@
import { createEffect, createMemo, createRoot, onCleanup } from "solid-js" import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { FileContent } from "@opencode-ai/sdk/v2" import type { FileContent, FileNode } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
@@ -39,6 +39,14 @@ export type FileState = {
content?: FileContent content?: FileContent
} }
type DirectoryState = {
expanded: boolean
loaded?: boolean
loading?: boolean
error?: string
children?: string[]
}
function stripFileProtocol(input: string) { function stripFileProtocol(input: string) {
if (!input.startsWith("file://")) return input if (!input.startsWith("file://")) return input
return input.slice("file://".length) return input.slice("file://".length)
@@ -285,6 +293,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
} }
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
const treeInflight = new Map<string, Promise<void>>()
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
file: Record<string, FileState> file: Record<string, FileState>
@@ -292,10 +301,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
file: {}, file: {},
}) })
const [tree, setTree] = createStore<{
node: Record<string, FileNode>
dir: Record<string, DirectoryState>
}>({
node: {},
dir: { "": { expanded: true } },
})
createEffect(() => { createEffect(() => {
scope() scope()
inflight.clear() inflight.clear()
treeInflight.clear()
setStore("file", {}) setStore("file", {})
setTree("node", {})
setTree("dir", { "": { expanded: true } })
}) })
const viewCache = new Map<string, ViewCacheEntry>() const viewCache = new Map<string, ViewCacheEntry>()
@@ -407,14 +427,156 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return promise return promise
} }
function normalizeDir(input: string) {
return normalize(input).replace(/\/+$/, "")
}
function ensureDir(path: string) {
if (tree.dir[path]) return
setTree("dir", path, { expanded: false })
}
function listDir(input: string, options?: { force?: boolean }) {
const dir = normalizeDir(input)
ensureDir(dir)
const current = tree.dir[dir]
if (!options?.force && current?.loaded) return Promise.resolve()
const pending = treeInflight.get(dir)
if (pending) return pending
setTree(
"dir",
dir,
produce((draft) => {
draft.loading = true
draft.error = undefined
}),
)
const directory = scope()
const promise = sdk.client.file
.list({ path: dir })
.then((x) => {
if (scope() !== directory) return
const nodes = x.data ?? []
const prevChildren = tree.dir[dir]?.children ?? []
const nextChildren = nodes.map((node) => node.path)
const nextSet = new Set(nextChildren)
setTree(
"node",
produce((draft) => {
const removedDirs: string[] = []
for (const child of prevChildren) {
if (nextSet.has(child)) continue
const existing = draft[child]
if (existing?.type === "directory") removedDirs.push(child)
delete draft[child]
}
if (removedDirs.length > 0) {
const keys = Object.keys(draft)
for (const key of keys) {
for (const removed of removedDirs) {
if (!key.startsWith(removed + "/")) continue
delete draft[key]
break
}
}
}
for (const node of nodes) {
draft[node.path] = node
}
}),
)
setTree(
"dir",
dir,
produce((draft) => {
draft.loaded = true
draft.loading = false
draft.children = nextChildren
}),
)
})
.catch((e) => {
if (scope() !== directory) return
setTree(
"dir",
dir,
produce((draft) => {
draft.loading = false
draft.error = e.message
}),
)
showToast({
variant: "error",
title: "Failed to list files",
description: e.message,
})
})
.finally(() => {
treeInflight.delete(dir)
})
treeInflight.set(dir, promise)
return promise
}
function expandDir(input: string) {
const dir = normalizeDir(input)
ensureDir(dir)
setTree("dir", dir, "expanded", true)
void listDir(dir)
}
function collapseDir(input: string) {
const dir = normalizeDir(input)
ensureDir(dir)
setTree("dir", dir, "expanded", false)
}
function dirState(input: string) {
const dir = normalizeDir(input)
return tree.dir[dir]
}
function children(input: string) {
const dir = normalizeDir(input)
const ids = tree.dir[dir]?.children
if (!ids) return []
const out: FileNode[] = []
for (const id of ids) {
const node = tree.node[id]
if (node) out.push(node)
}
return out
}
const stop = sdk.event.listen((e) => { const stop = sdk.event.listen((e) => {
const event = e.details const event = e.details
if (event.type !== "file.watcher.updated") return if (event.type !== "file.watcher.updated") return
const path = normalize(event.properties.file) const path = normalize(event.properties.file)
if (!path) return if (!path) return
if (path.startsWith(".git/")) return if (path.startsWith(".git/")) return
if (!store.file[path]) return
load(path, { force: true }) if (store.file[path]) {
load(path, { force: true })
}
const kind = event.properties.event
if (kind !== "add" && kind !== "unlink") return
const parent = path.split("/").slice(0, -1).join("/")
if (!tree.dir[parent]?.loaded) return
listDir(parent, { force: true })
}) })
const get = (input: string) => store.file[normalize(input)] const get = (input: string) => store.file[normalize(input)]
@@ -448,6 +610,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
normalize, normalize,
tab, tab,
pathFromTab, pathFromTab,
tree: {
list: listDir,
refresh: (input: string) => listDir(input, { force: true }),
state: dirState,
children,
expand: expandDir,
collapse: collapseDir,
toggle(input: string) {
if (dirState(input)?.expanded) {
collapseDir(input)
return
}
expandDir(input)
},
},
get, get,
load, load,
scrollTop, scrollTop,

View File

@@ -82,6 +82,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
diffStyle: "split" as ReviewDiffStyle, diffStyle: "split" as ReviewDiffStyle,
panelOpened: true, panelOpened: true,
}, },
fileTree: {
opened: false,
width: 260,
},
session: { session: {
width: 600, width: 600,
}, },
@@ -449,6 +453,38 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("review", "diffStyle", diffStyle) setStore("review", "diffStyle", diffStyle)
}, },
}, },
fileTree: {
opened: createMemo(() => store.fileTree?.opened ?? false),
width: createMemo(() => store.fileTree?.width ?? 260),
open() {
if (!store.fileTree) {
setStore("fileTree", { opened: true, width: 260 })
return
}
setStore("fileTree", "opened", true)
},
close() {
if (!store.fileTree) {
setStore("fileTree", { opened: false, width: 260 })
return
}
setStore("fileTree", "opened", false)
},
toggle() {
if (!store.fileTree) {
setStore("fileTree", { opened: true, width: 260 })
return
}
setStore("fileTree", "opened", (x) => !x)
},
resize(width: number) {
if (!store.fileTree) {
setStore("fileTree", { opened: true, width })
return
}
setStore("fileTree", "width", width)
},
},
session: { session: {
width: createMemo(() => store.session?.width ?? 600), width: createMemo(() => store.session?.width ?? 600),
resize(width: number) { resize(width: number) {

View File

@@ -32,6 +32,7 @@ import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array" import { findLast } from "@opencode-ai/util/array"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file" import { DialogSelectFile } from "@/components/dialog-select-file"
import FileTree from "@/components/file-tree"
import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectMcp } from "@/components/dialog-select-mcp" import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { DialogFork } from "@/components/dialog-fork" import { DialogFork } from "@/components/dialog-fork"
@@ -1811,8 +1812,29 @@ export default function Page() {
<aside <aside
id="review-panel" id="review-panel"
aria-label={language.t("session.panel.reviewAndFiles")} aria-label={language.t("session.panel.reviewAndFiles")}
class="relative flex-1 min-w-0 h-full border-l border-border-weak-base" class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex"
> >
<Show when={layout.fileTree.opened()}>
<div class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
<div class="h-full bg-background-base border-r border-border-weak-base flex flex-col">
<div class="hidden h-12 shrink-0 flex items-center px-3 border-b border-border-weak-base text-12-medium text-text-weak">
Files
</div>
<div class="flex-1 min-h-0 overflow-y-auto no-scrollbar p-2">
<FileTree path="" onFileClick={(node) => openTab(file.tab(node.path))} />
</div>
</div>
<ResizeHandle
direction="horizontal"
size={layout.fileTree.width()}
min={200}
max={480}
collapseThreshold={160}
onResize={layout.fileTree.resize}
onCollapse={layout.fileTree.close}
/>
</div>
</Show>
<DragDropProvider <DragDropProvider
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}