From d9eed4c6cacf59089e6b6d6101deff58c7bd5040 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 5 Jan 2026 00:25:44 -0600
Subject: [PATCH] feat(app): file tree
---
packages/app/src/components/file-tree.tsx | 188 +++++++++---------
.../src/components/session/session-header.tsx | 26 +++
packages/app/src/context/file.tsx | 183 ++++++++++++++++-
packages/app/src/context/layout.tsx | 36 ++++
packages/app/src/pages/session.tsx | 24 ++-
5 files changed, 364 insertions(+), 93 deletions(-)
diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx
index 3439d366c..791b33b4a 100644
--- a/packages/app/src/components/file-tree.tsx
+++ b/packages/app/src/components/file-tree.tsx
@@ -1,111 +1,121 @@
-import { useLocal, type LocalFile } from "@/context/local"
+import { useFile } from "@/context/file"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon"
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 type { FileNode } from "@opencode-ai/sdk/v2"
export default function FileTree(props: {
path: string
class?: string
nodeClass?: string
level?: number
- onFileClick?: (file: LocalFile) => void
+ onFileClick?: (file: FileNode) => void
}) {
- const local = useLocal()
+ const file = useFile()
const level = props.level ?? 0
- const Node = (p: ParentProps & ComponentProps<"div"> & { node: LocalFile; as?: "div" | "button" }) => (
- {
- const evt = e as globalThis.DragEvent
- evt.dataTransfer!.effectAllowed = "copy"
- evt.dataTransfer!.setData("text/plain", `file:${p.node.path}`)
+ createEffect(() => {
+ void file.tree.list(props.path)
+ })
- // Create custom drag image without margins
- const dragImage = document.createElement("div")
- dragImage.className =
- "flex items-center gap-x-2 px-2 py-1 bg-background-element rounded-md border border-border-1"
- dragImage.style.position = "absolute"
- dragImage.style.top = "-1000px"
-
- // Copy only the icon and text content without padding
- const icon = e.currentTarget.querySelector("svg")
- const text = e.currentTarget.querySelector("span")
- if (icon && text) {
- dragImage.innerHTML = icon.outerHTML + text.outerHTML
- }
-
- document.body.appendChild(dragImage)
- evt.dataTransfer!.setDragImage(dragImage, 0, 12)
- setTimeout(() => document.body.removeChild(dragImage), 0)
- }}
- {...p}
- >
- {p.children}
- &
+ ComponentProps<"button"> & {
+ node: FileNode
+ as?: "div" | "button"
+ },
+ ) => {
+ const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
+ 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}
>
- {p.node.name}
-
- {/* */}
- {/* */}
- {/* */}
-
- )
+ {local.children}
+
+ {local.node.name}
+
+
+ )
+ }
return (
-
-
- {(node) => (
-
-
-
- (open ? local.file.expand(node.path) : local.file.collapse(node.path))}
- >
-
-
-
-
-
-
-
-
-
-
-
-
- props.onFileClick?.(node)}>
-
-
-
-
-
-
- )}
+
+
+ {(node) => {
+ const expanded = () => file.tree.state(node.path)?.expanded ?? false
+ return (
+
+
+
+ (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ props.onFileClick?.(node)}>
+
+
+
+
+
+
+ )
+ }}
)
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index 90daa971d..8480e6060 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -280,6 +280,32 @@ export function SessionHeader() {
+
+
+
+
+