summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-05 00:25:44 -0600
committeradamelmore <[email protected]>2026-01-26 11:07:51 -0600
commitd9eed4c6cacf59089e6b6d6101deff58c7bd5040 (patch)
tree9baa833645787d9b9d66cfcbe8363962a85aa6b5 /packages
parent7e34d27b77b3ce51cda27110b4ad1d3d5bc4317c (diff)
downloadopencode-d9eed4c6cacf59089e6b6d6101deff58c7bd5040.tar.gz
opencode-d9eed4c6cacf59089e6b6d6101deff58c7bd5040.zip
feat(app): file tree
Diffstat (limited to 'packages')
-rw-r--r--packages/app/src/components/file-tree.tsx186
-rw-r--r--packages/app/src/components/session/session-header.tsx26
-rw-r--r--packages/app/src/context/file.tsx183
-rw-r--r--packages/app/src/context/layout.tsx36
-rw-r--r--packages/app/src/pages/session.tsx24
5 files changed, 363 insertions, 92 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" }) => (
- <Dynamic
- 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}`)
+ 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"
+ const Node = (
+ p: ParentProps &
+ ComponentProps<"div"> &
+ ComponentProps<"button"> & {
+ node: FileNode
+ as?: "div" | "button"
+ },
+ ) => {
+ const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
+ return (
+ <Dynamic
+ component={local.as ?? "div"}
+ classList={{
+ "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,
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ [props.nodeClass ?? ""]: !!props.nodeClass,
+ }}
+ 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"
- // 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
- }
+ 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"
- document.body.appendChild(dragImage)
- evt.dataTransfer!.setDragImage(dragImage, 0, 12)
- setTimeout(() => document.body.removeChild(dragImage), 0)
- }}
- {...p}
- >
- {p.children}
- <span
- classList={{
- "text-xs whitespace-nowrap truncate": true,
- "text-text-muted/40": p.node.ignored,
- "text-text-muted/80": !p.node.ignored,
- // "!text-text": local.file.active()?.path === p.node.path,
- // "!text-primary": local.file.changed(p.node.path),
+ 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}
- </span>
- {/* <Show when={local.file.changed(p.node.path)}> */}
- {/* <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" /> */}
- {/* </Show> */}
- </Dynamic>
- )
+ {local.children}
+ <span
+ classList={{
+ "text-12-regular whitespace-nowrap truncate": true,
+ "text-text-weaker": local.node.ignored,
+ "text-text-weak": !local.node.ignored,
+ }}
+ >
+ {local.node.name}
+ </span>
+ </Dynamic>
+ )
+ }
return (
- <div class={`flex flex-col ${props.class}`}>
- <For each={local.file.children(props.path)}>
- {(node) => (
- <Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right">
- <Switch>
- <Match when={node.type === "directory"}>
- <Collapsible
- variant="ghost"
- class="w-full"
- forceMount={false}
- // open={local.file.node(node.path)?.expanded}
- onOpenChange={(open) => (open ? local.file.expand(node.path) : local.file.collapse(node.path))}
- >
- <Collapsible.Trigger>
- <Node node={node}>
- <Collapsible.Arrow class="text-text-muted/60 ml-1" />
- <FileIcon
- node={node}
- // expanded={local.file.node(node.path).expanded}
- class="text-text-muted/60 -ml-1"
- />
- </Node>
- </Collapsible.Trigger>
- <Collapsible.Content>
- <FileTree path={node.path} level={level + 1} onFileClick={props.onFileClick} />
- </Collapsible.Content>
- </Collapsible>
- </Match>
- <Match when={node.type === "file"}>
- <Node node={node} as="button" onClick={() => props.onFileClick?.(node)}>
- <div class="w-4 shrink-0" />
- <FileIcon node={node} class="text-primary" />
- </Node>
- </Match>
- </Switch>
- </Tooltip>
- )}
+ <div class={`flex flex-col ${props.class ?? ""}`}>
+ <For each={file.tree.children(props.path)}>
+ {(node) => {
+ const expanded = () => file.tree.state(node.path)?.expanded ?? false
+ return (
+ <Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right">
+ <Switch>
+ <Match when={node.type === "directory"}>
+ <Collapsible
+ variant="ghost"
+ class="w-full"
+ forceMount={false}
+ open={expanded()}
+ onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
+ >
+ <Collapsible.Trigger>
+ <Node node={node}>
+ <Collapsible.Arrow class="text-icon-weak ml-1" />
+ <FileIcon node={node} expanded={expanded()} class="text-icon-weak -ml-1 size-4" />
+ </Node>
+ </Collapsible.Trigger>
+ <Collapsible.Content>
+ <FileTree path={node.path} level={level + 1} onFileClick={props.onFileClick} />
+ </Collapsible.Content>
+ </Collapsible>
+ </Match>
+ <Match when={node.type === "file"}>
+ <Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
+ <div class="w-4 shrink-0" />
+ <FileIcon node={node} class="text-icon-weak size-4" />
+ </Node>
+ </Match>
+ </Switch>
+ </Tooltip>
+ )
+ }}
</For>
</div>
)
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
@@ -281,6 +281,32 @@ export function SessionHeader() {
</TooltipKeybind>
</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">
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
<Button
variant="ghost"
diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx
index afde451ff..b5673f584 100644
--- a/packages/app/src/context/file.tsx
+++ b/packages/app/src/context/file.tsx
@@ -1,7 +1,7 @@
import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
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 { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/util/path"
@@ -39,6 +39,14 @@ export type FileState = {
content?: FileContent
}
+type DirectoryState = {
+ expanded: boolean
+ loaded?: boolean
+ loading?: boolean
+ error?: string
+ children?: string[]
+}
+
function stripFileProtocol(input: string) {
if (!input.startsWith("file://")) return input
return input.slice("file://".length)
@@ -285,6 +293,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}
const inflight = new Map<string, Promise<void>>()
+ const treeInflight = new Map<string, Promise<void>>()
const [store, setStore] = createStore<{
file: Record<string, FileState>
@@ -292,10 +301,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
file: {},
})
+ const [tree, setTree] = createStore<{
+ node: Record<string, FileNode>
+ dir: Record<string, DirectoryState>
+ }>({
+ node: {},
+ dir: { "": { expanded: true } },
+ })
+
createEffect(() => {
scope()
inflight.clear()
+ treeInflight.clear()
setStore("file", {})
+ setTree("node", {})
+ setTree("dir", { "": { expanded: true } })
})
const viewCache = new Map<string, ViewCacheEntry>()
@@ -407,14 +427,156 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
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 event = e.details
if (event.type !== "file.watcher.updated") return
const path = normalize(event.properties.file)
if (!path) 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)]
@@ -448,6 +610,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
normalize,
tab,
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,
load,
scrollTop,
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index 5bb6f92ec..414c3d6f1 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -82,6 +82,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
diffStyle: "split" as ReviewDiffStyle,
panelOpened: true,
},
+ fileTree: {
+ opened: false,
+ width: 260,
+ },
session: {
width: 600,
},
@@ -449,6 +453,38 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
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: {
width: createMemo(() => store.session?.width ?? 600),
resize(width: number) {
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 5e5cba69c..413d8ac34 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -32,6 +32,7 @@ import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
+import FileTree from "@/components/file-tree"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { DialogFork } from "@/components/dialog-fork"
@@ -1811,8 +1812,29 @@ export default function Page() {
<aside
id="review-panel"
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
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}