Adam 1 месяц назад
Родитель
Сommit
d9eed4c6ca

+ 98 - 88
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>
   )

+ 26 - 0
packages/app/src/components/session/session-header.tsx

@@ -280,6 +280,32 @@ export function SessionHeader() {
                   </Button>
                 </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

+ 180 - 3
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,

+ 36 - 0
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) {

+ 23 - 1
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}