summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components
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/app/src/components
parent7e34d27b77b3ce51cda27110b4ad1d3d5bc4317c (diff)
downloadopencode-d9eed4c6cacf59089e6b6d6101deff58c7bd5040.tar.gz
opencode-d9eed4c6cacf59089e6b6d6101deff58c7bd5040.zip
feat(app): file tree
Diffstat (limited to 'packages/app/src/components')
-rw-r--r--packages/app/src/components/file-tree.tsx186
-rw-r--r--packages/app/src/components/session/session-header.tsx26
2 files changed, 124 insertions, 88 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"