Przeglądaj źródła

feat: add tree view for the files in the current workspace (#601)

* feat: add tree view for the files in the current workspace

* minor

* rest endpoints

* minor
Alex Bäuerle 1 rok temu
rodzic
commit
d20f532289

+ 2 - 0
frontend/package.json

@@ -28,7 +28,9 @@
     "i18next-browser-languagedetector": "^7.2.1",
     "i18next-http-backend": "^2.5.0",
     "react": "^18.2.0",
+    "react-accessible-treeview": "^2.8.3",
     "react-dom": "^18.2.0",
+    "react-icons": "^5.0.1",
     "react-i18next": "^14.1.0",
     "react-redux": "^9.1.0",
     "react-syntax-highlighter": "^15.5.0",

Plik diff jest za duży
+ 424 - 233
frontend/pnpm-lock.yaml


+ 155 - 4
frontend/src/components/CodeEditor.tsx

@@ -1,8 +1,158 @@
-import React from "react";
 import Editor, { Monaco } from "@monaco-editor/react";
-import { useSelector } from "react-redux";
 import type { editor } from "monaco-editor";
-import { RootState } from "../store";
+import React, { useEffect, useState } from "react";
+import TreeView, { flattenTree } from "react-accessible-treeview";
+import { DiJavascript } from "react-icons/di";
+import {
+  FaCss3,
+  FaFile,
+  FaFolder,
+  FaFolderOpen,
+  FaHtml5,
+  FaList,
+  FaMarkdown,
+  FaNpm,
+  FaPython,
+} from "react-icons/fa";
+import { VscClose, VscListTree, VscRefresh } from "react-icons/vsc";
+import { useSelector } from "react-redux";
+import { getWorkspace, selectFile } from "../services/fileService";
+import { setCode, updateWorkspace } from "../state/codeSlice";
+import store, { RootState } from "../store";
+
+interface FileIconProps {
+  filename: string;
+}
+
+function FileIcon({ filename }: FileIconProps): JSX.Element | null {
+  const extension = filename.slice(filename.lastIndexOf(".") + 1);
+  switch (extension) {
+    case "js":
+      return <DiJavascript />;
+    case "ts":
+      return <DiJavascript />;
+    case "py":
+      return <FaPython />;
+    case "css":
+      return <FaCss3 />;
+    case "json":
+      return <FaList />;
+    case "npmignore":
+      return <FaNpm />;
+    case "html":
+      return <FaHtml5 />;
+    case "md":
+      return <FaMarkdown />;
+    default:
+      return <FaFile />;
+  }
+}
+
+interface FolderIconProps {
+  isOpen: boolean;
+}
+
+function FolderIcon({ isOpen }: FolderIconProps): JSX.Element {
+  return isOpen ? (
+    <FaFolderOpen color="D9D3D0" className="icon" />
+  ) : (
+    <FaFolder color="D9D3D0" className="icon" />
+  );
+}
+
+function Files(): JSX.Element | null {
+  const workspaceFolder = useSelector(
+    (state: RootState) => state.code.workspaceFolder,
+  );
+  const selectedIds = useSelector((state: RootState) => state.code.selectedIds);
+  const [explorerOpen, setExplorerOpen] = useState(true);
+  const workspaceTree = flattenTree(workspaceFolder);
+
+  useEffect(() => {
+    getWorkspace().then((file) => store.dispatch(updateWorkspace(file)));
+  }, []);
+
+  if (workspaceTree.length <= 1) {
+    return null;
+  }
+  if (!explorerOpen) {
+    return (
+      <div className="h-full bg-bg-workspace border-r-1 flex flex-col">
+        <div className="flex gap-1 border-b-1 p-1 justify-end">
+          <VscListTree
+            className="cursor-pointer"
+            onClick={() => setExplorerOpen(true)}
+          />
+        </div>
+      </div>
+    );
+  }
+  return (
+    <div className="min-w-[250px] h-full bg-bg-workspace border-r-1 flex flex-col">
+      <div className="flex gap-1 border-b-1 p-1 justify-end">
+        <VscRefresh
+          onClick={() =>
+            getWorkspace().then((file) => store.dispatch(updateWorkspace(file)))
+          }
+          className="cursor-pointer"
+        />
+        <VscClose
+          className="cursor-pointer"
+          onClick={() => setExplorerOpen(false)}
+        />
+      </div>
+      <div className="w-full overflow-x-auto h-full py-2">
+        <TreeView
+          className="font-mono text-sm"
+          data={workspaceTree}
+          selectedIds={selectedIds}
+          expandedIds={workspaceTree.map((node) => node.id)}
+          onNodeSelect={(node) => {
+            if (!node.isBranch) {
+              let fullPath = node.element.name;
+              let currentNode = workspaceTree.find(
+                (file) => file.id === node.element.id,
+              );
+              while (currentNode !== undefined && currentNode.parent) {
+                currentNode = workspaceTree.find(
+                  (file) => file.id === node.element.parent,
+                );
+                fullPath = `${currentNode!.name}/${fullPath}`;
+              }
+              selectFile(fullPath).then((code) => {
+                store.dispatch(setCode(code));
+              });
+            }
+          }}
+          // eslint-disable-next-line react/no-unstable-nested-components
+          nodeRenderer={({
+            element,
+            isBranch,
+            isExpanded,
+            getNodeProps,
+            level,
+          }) => (
+            <div
+              // eslint-disable-next-line react/jsx-props-no-spreading
+              {...getNodeProps()}
+              style={{ paddingLeft: 20 * (level - 1) }}
+              className="cursor-pointer nowrap flex items-center gap-2 aria-selected:bg-slate-500 hover:bg-slate-700"
+            >
+              <div className="shrink-0">
+                {isBranch ? (
+                  <FolderIcon isOpen={isExpanded} />
+                ) : (
+                  <FileIcon filename={element.name} />
+                )}
+              </div>
+              {element.name}
+            </div>
+          )}
+        />
+      </div>
+    </div>
+  );
+}
 
 function CodeEditor(): JSX.Element {
   const code = useSelector((state: RootState) => state.code.code);
@@ -30,7 +180,8 @@ function CodeEditor(): JSX.Element {
   };
 
   return (
-    <div className="w-full h-full bg-bg-workspace">
+    <div className="w-full h-full bg-bg-workspace flex">
+      <Files />
       <Editor
         height="95%"
         theme="vs-dark"

+ 8 - 6
frontend/src/services/actions.ts

@@ -1,12 +1,12 @@
-import store from "../store";
-import { ActionMessage } from "../types/Message";
 import { setScreenshotSrc, setUrl } from "../state/browserSlice";
 import { appendAssistantMessage } from "../state/chatSlice";
-import { setCode } from "../state/codeSlice";
-import { setInitialized } from "../state/taskSlice";
-import { handleObservationMessage } from "./observations";
+import { setCode, updatePath } from "../state/codeSlice";
 import { appendInput } from "../state/commandSlice";
+import { setInitialized } from "../state/taskSlice";
+import store from "../store";
+import { ActionMessage } from "../types/Message";
 import { SocketMessage } from "../types/ResponseType";
+import { handleObservationMessage } from "./observations";
 
 let isInitialized = false;
 
@@ -29,7 +29,9 @@ const messageActions = {
     store.dispatch(setScreenshotSrc(screenshotSrc));
   },
   write: (message: ActionMessage) => {
-    store.dispatch(setCode(message.args.content));
+    const { path, content } = message.args;
+    store.dispatch(updatePath(path));
+    store.dispatch(setCode(content));
   },
   think: (message: ActionMessage) => {
     store.dispatch(appendAssistantMessage(message.args.thought));

+ 14 - 0
frontend/src/services/fileService.ts

@@ -0,0 +1,14 @@
+export type WorkspaceFile = {
+  name: string;
+  children?: WorkspaceFile[];
+};
+
+export async function selectFile(file: string): Promise<string> {
+  const res = await fetch(`/api/select-file?file=${file}`);
+  return (await JSON.parse(await res.json()).code) as string;
+}
+
+export async function getWorkspace(): Promise<WorkspaceFile> {
+  const res = await fetch("/api/refresh-files");
+  return (await JSON.parse(await res.json())) as WorkspaceFile;
+}

+ 53 - 1
frontend/src/state/codeSlice.ts

@@ -1,17 +1,69 @@
 import { createSlice } from "@reduxjs/toolkit";
+import { INode, flattenTree } from "react-accessible-treeview";
+import { IFlatMetadata } from "react-accessible-treeview/dist/TreeView/utils";
+import { WorkspaceFile } from "../services/fileService";
 
 export const codeSlice = createSlice({
   name: "code",
   initialState: {
     code: "# Welcome to OpenDevin!",
+    selectedIds: [] as number[],
+    workspaceFolder: { name: "" } as WorkspaceFile,
   },
   reducers: {
     setCode: (state, action) => {
       state.code = action.payload;
     },
+    updatePath: (state, action) => {
+      const path = action.payload;
+      const pathParts = path.split("/");
+      let current = state.workspaceFolder;
+
+      for (let i = 0; i < pathParts.length - 1; i += 1) {
+        const folderName = pathParts[i];
+        let folder = current.children?.find((file) => file.name === folderName);
+
+        if (!folder) {
+          folder = { name: folderName, children: [] };
+          current.children?.push(folder);
+        }
+
+        current = folder;
+      }
+
+      const fileName = pathParts[pathParts.length - 1];
+      if (!current.children?.find((file) => file.name === fileName)) {
+        current.children?.push({ name: fileName });
+      }
+
+      const data = flattenTree(state.workspaceFolder);
+      const checkPath: (
+        file: INode<IFlatMetadata>,
+        pathIndex: number,
+      ) => boolean = (file, pathIndex) => {
+        if (pathIndex < 0) {
+          if (file.parent === null) return true;
+          return false;
+        }
+        if (pathIndex >= 0 && file.name !== pathParts[pathIndex]) {
+          return false;
+        }
+        return checkPath(
+          data.find((f) => f.id === file.parent)!,
+          pathIndex - 1,
+        );
+      };
+      const selected = data
+        .filter((file) => checkPath(file, pathParts.length - 1))
+        .map((file) => file.id) as number[];
+      state.selectedIds = selected;
+    },
+    updateWorkspace: (state, action) => {
+      state.workspaceFolder = action.payload;
+    },
   },
 });
 
-export const { setCode } = codeSlice.actions;
+export const { setCode, updatePath, updateWorkspace } = codeSlice.actions;
 
 export default codeSlice.reducer;

+ 8 - 7
opendevin/action/fileop.py

@@ -2,15 +2,17 @@ import os
 from dataclasses import dataclass
 
 from opendevin.observation import FileReadObservation, FileWriteObservation
+
 from .base import ExecutableAction
 
 # This is the path where the workspace is mounted in the container
 # The LLM sometimes returns paths with this prefix, so we need to remove it
 PATH_PREFIX = "/workspace/"
 
+
 def resolve_path(base_path, file_path):
     if file_path.startswith(PATH_PREFIX):
-        file_path = file_path[len(PATH_PREFIX):]
+        file_path = file_path[len(PATH_PREFIX) :]
     return os.path.join(base_path, file_path)
 
 
@@ -21,15 +23,14 @@ class FileReadAction(ExecutableAction):
 
     def run(self, controller) -> FileReadObservation:
         path = resolve_path(controller.workdir, self.path)
-        with open(path, 'r', encoding='utf-8') as file:
-            return FileReadObservation(
-                path=path,
-                content=file.read())
+        with open(path, "r", encoding="utf-8") as file:
+            return FileReadObservation(path=path, content=file.read())
 
     @property
     def message(self) -> str:
         return f"Reading file: {self.path}"
 
+
 @dataclass
 class FileWriteAction(ExecutableAction):
     path: str
@@ -37,8 +38,8 @@ class FileWriteAction(ExecutableAction):
     action: str = "write"
 
     def run(self, controller) -> FileWriteObservation:
-        path = resolve_path(controller.workdir, self.path)
-        with open(path, 'w', encoding='utf-8') as file:
+        whole_path = resolve_path(controller.workdir, self.path)
+        with open(whole_path, "w", encoding="utf-8") as file:
             file.write(self.content)
         return FileWriteObservation(content="", path=self.path)
 

+ 42 - 0
opendevin/files.py

@@ -0,0 +1,42 @@
+from pathlib import Path
+from typing import Any, Dict, List
+
+
+class WorkspaceFile:
+    name: str
+    children: List["WorkspaceFile"]
+
+    def __init__(self, name: str, children: List["WorkspaceFile"]):
+        self.name = name
+        self.children = children
+
+    def to_dict(self) -> Dict[str, Any]:
+        """Converts the File object to a dictionary.
+
+        Returns:
+            The dictionary representation of the File object.
+        """
+        return {
+            "name": self.name,
+            "children": [child.to_dict() for child in self.children],
+        }
+
+
+def get_folder_structure(workdir: Path) -> WorkspaceFile:
+    """Gets the folder structure of a directory.
+
+    Args:
+        workdir: The directory path.
+
+    Returns:
+        The folder structure.
+    """
+    root = WorkspaceFile(name=workdir.name, children=[])
+    for item in workdir.iterdir():
+        if item.is_dir():
+            dir = get_folder_structure(item)
+            if dir.children:
+                root.children.append(dir)
+        else:
+            root.children.append(WorkspaceFile(name=item.name, children=[]))
+    return root

+ 1 - 2
opendevin/server/agent/manager.py

@@ -7,11 +7,10 @@ from opendevin.action import (
     Action,
     NullAction,
 )
-from opendevin.observation import NullObservation
 from opendevin.agent import Agent
 from opendevin.controller import AgentController
 from opendevin.llm.llm import LLM
-from opendevin.observation import Observation, UserMessageObservation
+from opendevin.observation import NullObservation, Observation, UserMessageObservation
 from opendevin.server.session import session_manager
 
 DEFAULT_API_KEY = config.get("LLM_API_KEY")

+ 25 - 10
opendevin/server/listen.py

@@ -1,18 +1,20 @@
+import json
 import uuid
+from pathlib import Path
 
-from opendevin.server.session import session_manager, message_stack
-from opendevin.server.auth import get_sid_from_token, sign_token
-from opendevin.agent import Agent
-from opendevin.server.agent import AgentManager
-import agenthub  # noqa F401 (we import this to get the agents registered)
-
-from fastapi import FastAPI, WebSocket, Depends
-from fastapi.middleware.cors import CORSMiddleware
-from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
 import litellm
+from fastapi import Depends, FastAPI, WebSocket
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
 from starlette import status
 from starlette.responses import JSONResponse
-from opendevin import config
+
+import agenthub  # noqa F401 (we import this to get the agents registered)
+from opendevin import config, files
+from opendevin.agent import Agent
+from opendevin.server.agent import AgentManager
+from opendevin.server.auth import get_sid_from_token, sign_token
+from opendevin.server.session import message_stack, session_manager
 
 app = FastAPI()
 app.add_middleware(
@@ -112,3 +114,16 @@ async def del_messages(
 @app.get("/default-model")
 def read_default_model():
     return config.get_or_error("LLM_MODEL")
+
+
+@app.get("/refresh-files")
+def refresh_files():
+    structure = files.get_folder_structure(Path(str(config.get("WORKSPACE_DIR"))))
+    return json.dumps(structure.to_dict())
+
+
+@app.get("/select-file")
+def select_file(file: str):
+    with open(Path(Path(str(config.get("WORKSPACE_DIR"))), file), "r") as selected_file:
+        content = selected_file.read()
+    return json.dumps({"code": content})

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików