Jelajahi Sumber

Only list files one directory deep (#1853)

* modify api endpoint

* update frontend for backend

* fix fileservice

* rm file

* unskip test

* fix some more tests

* fix another test

* fix another test

* fix api call

* fix refresh for subdirs

* more tests passing

* more tests

* more tests

* another test

* logspam

* lint

* fix import

* logspam

* code review feedback

---------

Co-authored-by: Boxuan Li <liboxuan@connect.hku.hk>
Robert Brennan 1 tahun lalu
induk
melakukan
10933a2066

+ 0 - 1
frontend/package.json

@@ -23,7 +23,6 @@
     "jose": "^5.2.3",
     "monaco-editor": "^0.47.0",
     "react": "^18.2.0",
-    "react-accessible-treeview": "^2.8.3",
     "react-dom": "^18.2.0",
     "react-highlight": "^0.15.0",
     "react-hot-toast": "^2.4.1",

+ 10 - 8
frontend/src/components/CodeEditor.tsx

@@ -26,6 +26,7 @@ function CodeEditor(): JSX.Element {
 
   const dispatch = useDispatch();
   const code = useSelector((state: RootState) => state.code.code);
+  const activeFilepath = useSelector((state: RootState) => state.code.path);
 
   const handleEditorDidMount = (
     editor: editor.IStandaloneCodeEditor,
@@ -45,20 +46,21 @@ function CodeEditor(): JSX.Element {
     monaco.editor.setTheme("my-theme");
   };
 
-  const onSelectFile = async (absolutePath: string) => {
-    const paths = absolutePath.split("/");
-    const rootlessPath = paths.slice(1).join("/");
-
-    setSelectedFileAbsolutePath(absolutePath);
-
-    const newCode = await selectFile(rootlessPath);
+  const updateCode = async () => {
+    const newCode = await selectFile(activeFilepath);
+    setSelectedFileAbsolutePath(activeFilepath);
     dispatch(setCode(newCode));
   };
 
+  React.useEffect(() => {
+    // FIXME: we can probably move this out of the component and into state/service
+    if (activeFilepath) updateCode();
+  }, [activeFilepath]);
+
   return (
     <div className="flex h-full w-full bg-neutral-900 transition-all duration-500 ease-in-out">
       <CodeEditorContext.Provider value={codeEditorContext}>
-        <FileExplorer onFileClick={onSelectFile} />
+        <FileExplorer />
         <div className="flex flex-col min-h-0 w-full">
           <Tabs
             disableCursorAnimation

+ 9 - 22
frontend/src/components/file-explorer/ExplorerTree.test.tsx

@@ -1,17 +1,8 @@
 import React from "react";
-import { render } from "@testing-library/react";
+import { renderWithProviders } from "test-utils";
 import ExplorerTree from "./ExplorerTree";
-import { WorkspaceFile } from "#/services/fileService";
 
-const NODE: WorkspaceFile = {
-  name: "root-folder-1",
-  children: [
-    { name: "file-1-1.ts" },
-    { name: "folder-1-2", children: [{ name: "file-1-2.ts" }] },
-  ],
-};
-
-const onFileClick = vi.fn();
+const FILES = ["file-1-1.ts", "folder-1-2"];
 
 describe("ExplorerTree", () => {
   afterEach(() => {
@@ -19,25 +10,21 @@ describe("ExplorerTree", () => {
   });
 
   it("should render the explorer", () => {
-    const { getByText, queryByText } = render(
-      <ExplorerTree root={NODE} onFileClick={onFileClick} defaultOpen />,
+    const { getByText } = renderWithProviders(
+      <ExplorerTree files={FILES} defaultOpen />,
     );
 
-    expect(getByText("root-folder-1")).toBeInTheDocument();
     expect(getByText("file-1-1.ts")).toBeInTheDocument();
     expect(getByText("folder-1-2")).toBeInTheDocument();
-    expect(queryByText("file-1-2.ts")).not.toBeInTheDocument();
+    // TODO: make sure children render
   });
 
   it("should render the explorer given the defaultExpanded prop", () => {
-    const { getByText, queryByText } = render(
-      <ExplorerTree root={NODE} onFileClick={onFileClick} />,
-    );
+    const { queryByText } = renderWithProviders(<ExplorerTree files={FILES} />);
 
-    expect(getByText("root-folder-1")).toBeInTheDocument();
-    expect(queryByText("file-1-1.ts")).not.toBeInTheDocument();
-    expect(queryByText("folder-1-2")).not.toBeInTheDocument();
-    expect(queryByText("file-1-2.ts")).not.toBeInTheDocument();
+    expect(queryByText("file-1-1.ts")).toBeInTheDocument();
+    expect(queryByText("folder-1-2")).toBeInTheDocument();
+    // TODO: make sure children don't render
   });
 
   it.todo("should render all children as collapsed when defaultOpen is false");

+ 5 - 14
frontend/src/components/file-explorer/ExplorerTree.tsx

@@ -1,26 +1,17 @@
 import React from "react";
 import TreeNode from "./TreeNode";
-import { WorkspaceFile } from "#/services/fileService";
 
 interface ExplorerTreeProps {
-  root: WorkspaceFile;
-  onFileClick: (path: string) => void;
+  files: string[];
   defaultOpen?: boolean;
 }
 
-function ExplorerTree({
-  root,
-  onFileClick,
-  defaultOpen = false,
-}: ExplorerTreeProps) {
+function ExplorerTree({ files, defaultOpen = false }: ExplorerTreeProps) {
   return (
     <div className="w-full overflow-x-auto h-full pt-[4px]">
-      <TreeNode
-        node={root}
-        path={root.name}
-        onFileClick={onFileClick}
-        defaultOpen={defaultOpen}
-      />
+      {files.map((file) => (
+        <TreeNode key={file} path={file} defaultOpen={defaultOpen} />
+      ))}
     </div>
   );
 }

+ 30 - 44
frontend/src/components/file-explorer/FileExplorer.test.tsx

@@ -1,22 +1,25 @@
 import React from "react";
-import { render, waitFor, screen } from "@testing-library/react";
+import { waitFor, screen } from "@testing-library/react";
 import userEvent from "@testing-library/user-event";
 import { act } from "react-dom/test-utils";
+import { renderWithProviders } from "test-utils";
 import { describe, it, expect, vi, Mock } from "vitest";
 import FileExplorer from "./FileExplorer";
-import { getWorkspace, uploadFiles } from "#/services/fileService";
+import { uploadFiles, listFiles } from "#/services/fileService";
 import toast from "#/utils/toast";
 
 const toastSpy = vi.spyOn(toast, "stickyError");
 
 vi.mock("../../services/fileService", async () => ({
-  getWorkspace: vi.fn(async () => ({
-    name: "root",
-    children: [
-      { name: "file1.ts" },
-      { name: "folder1", children: [{ name: "file2.ts" }] },
-    ],
-  })),
+  listFiles: vi.fn(async (path: string = "/") => {
+    if (path === "/") {
+      return Promise.resolve(["folder1/", "file1.ts"]);
+    }
+    if (path === "/folder1/" || path === "folder1/") {
+      return Promise.resolve(["file2.ts"]);
+    }
+    return Promise.resolve([]);
+  }),
 
   uploadFiles: vi.fn(),
 }));
@@ -27,57 +30,40 @@ describe("FileExplorer", () => {
   });
 
   it("should get the workspace directory", async () => {
-    const { getByText } = render(<FileExplorer onFileClick={vi.fn} />);
+    const { getByText } = renderWithProviders(<FileExplorer />);
 
-    expect(getWorkspace).toHaveBeenCalledTimes(1);
     await waitFor(() => {
-      expect(getByText("root")).toBeInTheDocument();
+      expect(getByText("folder1")).toBeInTheDocument();
+      expect(getByText("file2.ts")).toBeInTheDocument();
     });
+    expect(listFiles).toHaveBeenCalledTimes(2); // once for root, once for folder1
   });
 
   it.todo("should render an empty workspace");
 
-  it("calls the onFileClick function when a file is clicked", async () => {
-    const onFileClickMock = vi.fn();
-    const { getByText } = render(
-      <FileExplorer onFileClick={onFileClickMock} />,
-    );
-
+  it.only("should refetch the workspace when clicking the refresh button", async () => {
+    const { getByText } = renderWithProviders(<FileExplorer />);
     await waitFor(() => {
       expect(getByText("folder1")).toBeInTheDocument();
+      expect(getByText("file2.ts")).toBeInTheDocument();
     });
-
-    act(() => {
-      userEvent.click(getByText("folder1"));
-    });
-
-    act(() => {
-      userEvent.click(getByText("file2.ts"));
-    });
-
-    const absPath = "root/folder1/file2.ts";
-    expect(onFileClickMock).toHaveBeenCalledWith(absPath);
-  });
-
-  it("should refetch the workspace when clicking the refresh button", async () => {
-    const onFileClickMock = vi.fn();
-    render(<FileExplorer onFileClick={onFileClickMock} />);
+    expect(listFiles).toHaveBeenCalledTimes(2); // once for root, once for folder 1
 
     // The 'await' keyword is required here to avoid a warning during test runs
     await act(() => {
       userEvent.click(screen.getByTestId("refresh"));
     });
 
-    expect(getWorkspace).toHaveBeenCalledTimes(2); // 1 from initial render, 1 from refresh button
+    expect(listFiles).toHaveBeenCalledTimes(4); // 2 from initial render, 2 from refresh button
   });
 
   it("should toggle the explorer visibility when clicking the close button", async () => {
-    const { getByTestId, getByText, queryByText } = render(
-      <FileExplorer onFileClick={vi.fn} />,
+    const { getByTestId, getByText, queryByText } = renderWithProviders(
+      <FileExplorer />,
     );
 
     await waitFor(() => {
-      expect(getByText("root")).toBeInTheDocument();
+      expect(getByText("folder1")).toBeInTheDocument();
     });
 
     act(() => {
@@ -85,13 +71,13 @@ describe("FileExplorer", () => {
     });
 
     // it should be hidden rather than removed from the DOM
-    expect(queryByText("root")).toBeInTheDocument();
-    expect(queryByText("root")).not.toBeVisible();
+    expect(queryByText("folder1")).toBeInTheDocument();
+    expect(queryByText("folder1")).not.toBeVisible();
   });
 
   it("should upload files", async () => {
     // TODO: Improve this test by passing expected argument to `uploadFiles`
-    const { getByTestId } = render(<FileExplorer onFileClick={vi.fn} />);
+    const { getByTestId } = renderWithProviders(<FileExplorer />);
     const file = new File([""], "file-name");
     const file2 = new File([""], "file-name-2");
 
@@ -103,7 +89,7 @@ describe("FileExplorer", () => {
     });
 
     expect(uploadFiles).toHaveBeenCalledOnce();
-    expect(getWorkspace).toHaveBeenCalled();
+    expect(listFiles).toHaveBeenCalled();
 
     const uploadDirInput = getByTestId("file-input");
 
@@ -113,7 +99,7 @@ describe("FileExplorer", () => {
     });
 
     expect(uploadFiles).toHaveBeenCalledTimes(2);
-    expect(getWorkspace).toHaveBeenCalled();
+    expect(listFiles).toHaveBeenCalled();
   });
 
   it.skip("should upload files when dragging them to the explorer", () => {
@@ -127,7 +113,7 @@ describe("FileExplorer", () => {
   it.todo("should display an error toast if file upload fails", async () => {
     (uploadFiles as Mock).mockRejectedValue(new Error());
 
-    const { getByTestId } = render(<FileExplorer onFileClick={vi.fn} />);
+    const { getByTestId } = renderWithProviders(<FileExplorer />);
 
     const uploadFileInput = getByTestId("file-input");
     const file = new File([""], "test");

+ 17 - 31
frontend/src/components/file-explorer/FileExplorer.tsx

@@ -5,16 +5,13 @@ import {
   IoIosRefresh,
   IoIosCloudUpload,
 } from "react-icons/io";
+import { useDispatch } from "react-redux";
 import { IoFileTray } from "react-icons/io5";
 import { twMerge } from "tailwind-merge";
-import {
-  WorkspaceFile,
-  getWorkspace,
-  uploadFiles,
-} from "#/services/fileService";
+import { setRefreshID } from "#/state/codeSlice";
+import { listFiles, uploadFiles } from "#/services/fileService";
 import IconButton from "../IconButton";
 import ExplorerTree from "./ExplorerTree";
-import { removeEmptyNodes } from "./utils";
 import toast from "#/utils/toast";
 
 interface ExplorerActionsProps {
@@ -86,31 +83,26 @@ function ExplorerActions({
   );
 }
 
-interface FileExplorerProps {
-  onFileClick: (path: string) => void;
-}
-
-function FileExplorer({ onFileClick }: FileExplorerProps) {
-  const [workspace, setWorkspace] = React.useState<WorkspaceFile>();
+function FileExplorer() {
   const [isHidden, setIsHidden] = React.useState(false);
   const [isDragging, setIsDragging] = React.useState(false);
-
+  const [files, setFiles] = React.useState<string[]>([]);
   const fileInputRef = React.useRef<HTMLInputElement | null>(null);
+  const dispatch = useDispatch();
 
-  const getWorkspaceData = async () => {
-    const wsFile = await getWorkspace();
-    setWorkspace(removeEmptyNodes(wsFile));
+  const selectFileInput = () => {
+    fileInputRef.current?.click(); // Trigger the file browser
   };
 
-  const selectFileInput = async () => {
-    // Trigger the file browser
-    fileInputRef.current?.click();
+  const refreshWorkspace = async () => {
+    dispatch(setRefreshID(Math.random()));
+    setFiles(await listFiles("/"));
   };
 
-  const uploadFileData = async (files: FileList) => {
+  const uploadFileData = async (toAdd: FileList) => {
     try {
-      await uploadFiles(files);
-      await getWorkspaceData(); // Refresh the workspace to show the new file
+      await uploadFiles(toAdd);
+      await refreshWorkspace();
     } catch (error) {
       toast.stickyError("ws", "Error uploading file");
     }
@@ -118,7 +110,7 @@ function FileExplorer({ onFileClick }: FileExplorerProps) {
 
   React.useEffect(() => {
     (async () => {
-      await getWorkspaceData();
+      await refreshWorkspace();
     })();
 
     const enableDragging = () => {
@@ -162,19 +154,13 @@ function FileExplorer({ onFileClick }: FileExplorerProps) {
       >
         <div className="flex p-2 items-center justify-between relative">
           <div style={{ display: isHidden ? "none" : "block" }}>
-            {workspace && (
-              <ExplorerTree
-                root={workspace}
-                onFileClick={onFileClick}
-                defaultOpen
-              />
-            )}
+            <ExplorerTree files={files} defaultOpen />
           </div>
 
           <ExplorerActions
             isHidden={isHidden}
             toggleHidden={() => setIsHidden((prev) => !prev)}
-            onRefresh={getWorkspaceData}
+            onRefresh={refreshWorkspace}
             onUpload={selectFileInput}
           />
         </div>

+ 75 - 93
frontend/src/components/file-explorer/TreeNode.test.tsx

@@ -1,148 +1,130 @@
 import React from "react";
-import { act, render } from "@testing-library/react";
+import { waitFor, act } from "@testing-library/react";
 import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "test-utils";
 import TreeNode from "./TreeNode";
-import { WorkspaceFile } from "#/services/fileService";
-
-const onFileClick = vi.fn();
-
-const NODE: WorkspaceFile = {
-  name: "folder",
-  children: [
-    { name: "file.ts" },
-    { name: "folder2", children: [{ name: "file2.ts" }] },
-  ],
-};
+import { selectFile, listFiles } from "#/services/fileService";
+
+vi.mock("../../services/fileService", async () => ({
+  listFiles: vi.fn(async (path: string = "/") => {
+    if (path === "/") {
+      return Promise.resolve(["folder1/", "file1.ts"]);
+    }
+    if (path === "/folder1/" || path === "folder1/") {
+      return Promise.resolve(["file2.ts"]);
+    }
+    return Promise.resolve([]);
+  }),
+  selectFile: vi.fn(async () => Promise.resolve({ code: "Hello world!" })),
+  uploadFile: vi.fn(),
+}));
 
 describe("TreeNode", () => {
   afterEach(() => {
-    vi.resetAllMocks();
+    vi.clearAllMocks();
   });
 
   it("should render a file if property has no children", () => {
-    const { getByText } = render(
-      <TreeNode
-        node={NODE}
-        path={NODE.name}
-        onFileClick={onFileClick}
-        defaultOpen
-      />,
+    const { getByText } = renderWithProviders(
+      <TreeNode path="/file.ts" defaultOpen />,
     );
 
     expect(getByText("file.ts")).toBeInTheDocument();
   });
 
-  it("should render a folder if property has children", () => {
-    const { getByText } = render(
-      <TreeNode
-        node={NODE}
-        path={NODE.name}
-        onFileClick={onFileClick}
-        defaultOpen
-      />,
+  it("should render a folder if it's in a subdir", async () => {
+    const { findByText } = renderWithProviders(
+      <TreeNode path="/folder1/" defaultOpen />,
     );
+    expect(listFiles).toHaveBeenCalledWith("/folder1/");
 
-    expect(getByText("folder")).toBeInTheDocument();
-    expect(getByText("file.ts")).toBeInTheDocument();
+    expect(await findByText("folder1")).toBeInTheDocument();
+    expect(await findByText("file2.ts")).toBeInTheDocument();
   });
 
-  it("should close a folder when clicking on it", () => {
-    const { getByText, queryByText } = render(
-      <TreeNode
-        node={NODE}
-        path={NODE.name}
-        onFileClick={onFileClick}
-        defaultOpen
-      />,
+  it("should close a folder when clicking on it", async () => {
+    const { findByText, queryByText } = renderWithProviders(
+      <TreeNode path="/folder1/" defaultOpen />,
     );
 
-    act(() => {
-      userEvent.click(getByText("folder"));
+    expect(await findByText("folder1")).toBeInTheDocument();
+    expect(await findByText("file2.ts")).toBeInTheDocument();
+
+    act(async () => {
+      userEvent.click(await findByText("folder1"));
     });
 
-    expect(queryByText("folder2")).not.toBeInTheDocument();
-    expect(queryByText("file2.ts")).not.toBeInTheDocument();
-    expect(queryByText("file.ts")).not.toBeInTheDocument();
+    expect(await findByText("folder1")).toBeInTheDocument();
+    expect(await queryByText("file2.ts")).not.toBeInTheDocument();
   });
 
-  it("should open a folder when clicking on it", () => {
-    const { getByText } = render(
-      <TreeNode node={NODE} path={NODE.name} onFileClick={onFileClick} />,
+  it("should open a folder when clicking on it", async () => {
+    const { getByText, findByText, queryByText } = renderWithProviders(
+      <TreeNode path="/folder1/" />,
     );
 
+    expect(await findByText("folder1")).toBeInTheDocument();
+    expect(await queryByText("file2.ts")).not.toBeInTheDocument();
+
     act(() => {
-      userEvent.click(getByText("folder"));
+      userEvent.click(getByText("folder1"));
     });
+    expect(listFiles).toHaveBeenCalledWith("/folder1/");
 
-    expect(getByText("folder2")).toBeInTheDocument();
-    expect(getByText("file.ts")).toBeInTheDocument();
+    expect(await findByText("folder1")).toBeInTheDocument();
+    expect(await findByText("file2.ts")).toBeInTheDocument();
   });
 
-  it("should call a fn and return the full path of a file when clicking on it", () => {
-    const { getByText } = render(
-      <TreeNode
-        node={NODE}
-        path={NODE.name}
-        onFileClick={onFileClick}
-        defaultOpen
-      />,
+  it.only("should call a fn and return the full path of a file when clicking on it", () => {
+    const { getByText } = renderWithProviders(
+      <TreeNode path="/folder1/file2.ts" defaultOpen />,
     );
 
-    act(() => {
-      userEvent.click(getByText("file.ts"));
-    });
-
-    expect(onFileClick).toHaveBeenCalledWith("folder/file.ts");
-
-    act(() => {
-      userEvent.click(getByText("folder2"));
-    });
-
     act(() => {
       userEvent.click(getByText("file2.ts"));
     });
 
-    expect(onFileClick).toHaveBeenCalledWith("folder/folder2/file2.ts");
+    waitFor(() => {
+      expect(selectFile).toHaveBeenCalledWith("/folder1/file2.ts");
+    });
   });
 
-  it("should render the explorer given the defaultExpanded prop", () => {
-    const { getByText, queryByText } = render(
-      <TreeNode node={NODE} path={NODE.name} onFileClick={onFileClick} />,
+  it("should render the explorer given the defaultOpen prop", async () => {
+    const { getByText, findByText, queryByText } = renderWithProviders(
+      <TreeNode path="/" defaultOpen />,
     );
 
-    expect(getByText("folder")).toBeInTheDocument();
-    expect(queryByText("folder2")).not.toBeInTheDocument();
-    expect(queryByText("file2.ts")).not.toBeInTheDocument();
-    expect(queryByText("file.ts")).not.toBeInTheDocument();
+    expect(listFiles).toHaveBeenCalledWith("/");
+
+    expect(await findByText("file1.ts")).toBeInTheDocument();
+    expect(await findByText("folder1")).toBeInTheDocument();
+    expect(await queryByText("file2.ts")).not.toBeInTheDocument();
 
     act(() => {
-      userEvent.click(getByText("folder"));
+      userEvent.click(getByText("folder1"));
     });
 
-    expect(getByText("folder2")).toBeInTheDocument();
-    expect(getByText("file.ts")).toBeInTheDocument();
+    expect(listFiles).toHaveBeenCalledWith("folder1/");
+
+    expect(await findByText("file1.ts")).toBeInTheDocument();
+    expect(await findByText("folder1")).toBeInTheDocument();
+    expect(await findByText("file2.ts")).toBeInTheDocument();
   });
 
-  it("should render all children as collapsed when defaultOpen is false", () => {
-    const { getByText, queryByText } = render(
-      <TreeNode node={NODE} path={NODE.name} onFileClick={onFileClick} />,
+  it("should render all children as collapsed when defaultOpen is false", async () => {
+    const { findByText, getByText, queryByText } = renderWithProviders(
+      <TreeNode path="/folder1/" />,
     );
 
-    expect(getByText("folder")).toBeInTheDocument();
-    expect(queryByText("folder2")).not.toBeInTheDocument();
-    expect(queryByText("file2.ts")).not.toBeInTheDocument();
-    expect(queryByText("file.ts")).not.toBeInTheDocument();
+    expect(await findByText("folder1")).toBeInTheDocument();
+    expect(await queryByText("file2.ts")).not.toBeInTheDocument();
 
     act(() => {
-      userEvent.click(getByText("folder"));
+      userEvent.click(getByText("folder1"));
     });
+    expect(listFiles).toHaveBeenCalledWith("/folder1/");
 
-    expect(getByText("folder2")).toBeInTheDocument();
-    expect(getByText("file.ts")).toBeInTheDocument();
-    expect(queryByText("file2.ts")).not.toBeInTheDocument();
+    expect(await findByText("folder1")).toBeInTheDocument();
+    expect(await findByText("file2.ts")).toBeInTheDocument();
   });
-
-  it.todo(
-    "should maintain the expanded state of child folders when closing and opening a parent folder",
-  );
 });

+ 37 - 23
frontend/src/components/file-explorer/TreeNode.tsx

@@ -1,8 +1,11 @@
 import React from "react";
+import { useDispatch, useSelector } from "react-redux";
 import { twMerge } from "tailwind-merge";
+import { RootState } from "#/store";
 import FolderIcon from "../FolderIcon";
 import FileIcon from "../FileIcons";
-import { WorkspaceFile } from "#/services/fileService";
+import { listFiles } from "#/services/fileService";
+import { setActiveFilepath } from "#/state/codeSlice";
 import { CodeEditorContext } from "../CodeEditorContext";
 
 interface TitleProps {
@@ -26,28 +29,44 @@ function Title({ name, type, isOpen, onClick }: TitleProps) {
 }
 
 interface TreeNodeProps {
-  node: WorkspaceFile;
   path: string;
-  onFileClick: (path: string) => void;
   defaultOpen?: boolean;
 }
 
-function TreeNode({
-  node,
-  path,
-  onFileClick,
-  defaultOpen = false,
-}: TreeNodeProps) {
+function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
   const [isOpen, setIsOpen] = React.useState(defaultOpen);
+  const [children, setChildren] = React.useState<string[] | null>(null);
   const { selectedFileAbsolutePath } = React.useContext(CodeEditorContext);
+  const refreshID = useSelector((state: RootState) => state.code.refreshID);
 
-  const handleClick = React.useCallback(() => {
-    if (node.children) {
+  const dispatch = useDispatch();
+
+  const fileParts = path.split("/");
+  const filename =
+    fileParts[fileParts.length - 1] || fileParts[fileParts.length - 2];
+
+  const isDirectory = path.endsWith("/");
+
+  const refreshChildren = async () => {
+    if (!isDirectory || !isOpen) {
+      setChildren(null);
+      return;
+    }
+    const files = await listFiles(path);
+    setChildren(files);
+  };
+
+  React.useEffect(() => {
+    refreshChildren();
+  }, [refreshID, isOpen]);
+
+  const handleClick = () => {
+    if (isDirectory) {
       setIsOpen((prev) => !prev);
     } else {
-      onFileClick(path);
+      dispatch(setActiveFilepath(path));
     }
-  }, [node, path, onFileClick]);
+  };
 
   return (
     <div
@@ -57,21 +76,16 @@ function TreeNode({
       )}
     >
       <Title
-        name={node.name}
-        type={node.children ? "folder" : "file"}
+        name={filename}
+        type={isDirectory ? "folder" : "file"}
         isOpen={isOpen}
         onClick={handleClick}
       />
 
-      {isOpen && node.children && (
+      {isOpen && children && (
         <div className="ml-5">
-          {node.children.map((child, index) => (
-            <TreeNode
-              key={index}
-              node={child}
-              path={`${path}/${child.name}`}
-              onFileClick={onFileClick}
-            />
+          {children.map((child, index) => (
+            <TreeNode key={index} path={`${child}`} />
           ))}
         </div>
       )}

+ 0 - 39
frontend/src/components/file-explorer/utils.test.ts

@@ -1,39 +0,0 @@
-import { removeEmptyNodes } from "./utils";
-
-test("removeEmptyNodes removes empty arrays", () => {
-  const root = {
-    name: "a",
-    children: [
-      {
-        name: "b",
-        children: [],
-      },
-      {
-        name: "c",
-        children: [
-          {
-            name: "d",
-            children: [],
-          },
-        ],
-      },
-    ],
-  };
-
-  expect(removeEmptyNodes(root)).toEqual({
-    name: "a",
-    children: [
-      {
-        name: "b",
-      },
-      {
-        name: "c",
-        children: [
-          {
-            name: "d",
-          },
-        ],
-      },
-    ],
-  });
-});

+ 0 - 14
frontend/src/components/file-explorer/utils.ts

@@ -1,14 +0,0 @@
-import { WorkspaceFile } from "#/services/fileService";
-
-export const removeEmptyNodes = (root: WorkspaceFile): WorkspaceFile => {
-  if (root.children) {
-    const children = root.children
-      .map(removeEmptyNodes)
-      .filter((node) => node !== undefined);
-    return {
-      ...root,
-      children: children.length ? children : undefined,
-    };
-  }
-  return root;
-};

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

@@ -1,6 +1,6 @@
 import { setScreenshotSrc, setUrl } from "#/state/browserSlice";
-import { addAssistantMessage } from "#/state/chatSlice";
-import { setCode, updatePath } from "#/state/codeSlice";
+import { addAssistantMessage, addUserMessage } from "#/state/chatSlice";
+import { setCode, setActiveFilepath } from "#/state/codeSlice";
 import { appendInput } from "#/state/commandSlice";
 import { appendJupyterInput } from "#/state/jupyterSlice";
 import { setRootTask } from "#/state/taskSlice";
@@ -24,11 +24,15 @@ const messageActions = {
   },
   [ActionType.WRITE]: (message: ActionMessage) => {
     const { path, content } = message.args;
-    store.dispatch(updatePath(path));
+    store.dispatch(setActiveFilepath(path));
     store.dispatch(setCode(content));
   },
   [ActionType.MESSAGE]: (message: ActionMessage) => {
-    store.dispatch(addAssistantMessage(message.args.content));
+    if (message.source === "user") {
+      store.dispatch(addUserMessage(message.args.content));
+    } else {
+      store.dispatch(addAssistantMessage(message.args.content));
+    }
   },
   [ActionType.FINISH]: (message: ActionMessage) => {
     store.dispatch(addAssistantMessage(message.message));

+ 3 - 8
frontend/src/services/fileService.ts

@@ -1,8 +1,3 @@
-export type WorkspaceFile = {
-  name: string;
-  children?: WorkspaceFile[];
-};
-
 export async function selectFile(file: string): Promise<string> {
   const res = await fetch(`/api/select-file?file=${file}`);
   const data = await res.json();
@@ -30,8 +25,8 @@ export async function uploadFiles(files: FileList) {
   }
 }
 
-export async function getWorkspace(): Promise<WorkspaceFile> {
-  const res = await fetch("/api/refresh-files");
+export async function listFiles(path: string = "/"): Promise<string[]> {
+  const res = await fetch(`/api/list-files?path=${path}`);
   const data = await res.json();
-  return data as WorkspaceFile;
+  return data as string[];
 }

+ 7 - 51
frontend/src/state/codeSlice.ts

@@ -1,12 +1,9 @@
 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 initialState = {
   code: "",
-  selectedIds: [] as number[],
-  workspaceFolder: { name: "" } as WorkspaceFile,
+  path: "",
+  refreshID: 0,
 };
 
 export const codeSlice = createSlice({
@@ -16,56 +13,15 @@ export const codeSlice = createSlice({
     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;
+    setActiveFilepath: (state, action) => {
+      state.path = action.payload;
     },
-    updateWorkspace: (state, action) => {
-      state.workspaceFolder = action.payload;
+    setRefreshID: (state, action) => {
+      state.refreshID = action.payload;
     },
   },
 });
 
-export const { setCode, updatePath, updateWorkspace } = codeSlice.actions;
+export const { setCode, setActiveFilepath, setRefreshID } = codeSlice.actions;
 
 export default codeSlice.reducer;

+ 3 - 0
frontend/src/types/Message.tsx

@@ -1,4 +1,7 @@
 export interface ActionMessage {
+  // Either 'agent' or 'user'
+  source: string;
+
   // The action to be taken
   action: string;
 

+ 24 - 9
opendevin/server/listen.py

@@ -1,3 +1,4 @@
+import os
 import shutil
 import uuid
 import warnings
@@ -6,7 +7,7 @@ from pathlib import Path
 with warnings.catch_warnings():
     warnings.simplefilter('ignore')
     import litellm
-from fastapi import Depends, FastAPI, Response, UploadFile, WebSocket, status
+from fastapi import Depends, FastAPI, Request, Response, UploadFile, WebSocket, status
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import JSONResponse, RedirectResponse
 from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -17,7 +18,6 @@ from opendevin.controller.agent import Agent
 from opendevin.core.config import config
 from opendevin.core.logger import opendevin_logger as logger
 from opendevin.llm import bedrock
-from opendevin.runtime import files
 from opendevin.server.agent import agent_manager
 from opendevin.server.auth import get_sid_from_token, sign_token
 from opendevin.server.session import message_stack, session_manager
@@ -225,18 +225,33 @@ async def del_messages(
     return {'ok': True}
 
 
-@app.get('/api/refresh-files')
-def refresh_files():
+@app.get('/api/list-files')
+def list_files(request: Request, path: str = '/'):
     """
-    Refresh files.
+    List files.
 
-    To refresh files:
+    To list files:
     ```sh
-    curl http://localhost:3000/api/refresh-files
+    curl http://localhost:3000/api/list-files
     ```
     """
-    structure = files.get_folder_structure(Path(str(config.workspace_base)))
-    return structure.to_dict()
+    if path.startswith('/'):
+        path = path[1:]
+    abs_path = os.path.join(config.workspace_base, path)
+    try:
+        files = os.listdir(abs_path)
+    except Exception as e:
+        logger.error(f'Error listing files: {e}', exc_info=False)
+        return JSONResponse(
+            status_code=status.HTTP_404_NOT_FOUND,
+            content={'error': 'Path not found'},
+        )
+    files = [os.path.join(path, f) for f in files]
+    files = [
+        f + '/' if os.path.isdir(os.path.join(config.workspace_base, f)) else f
+        for f in files
+    ]
+    return files
 
 
 @app.get('/api/select-file')

+ 2 - 7
opendevin/server/mock/listen.py

@@ -62,14 +62,9 @@ async def get_message_total():
     return {'msg_total': 0}
 
 
-@app.get('/api/refresh-files')
+@app.get('/api/list-files')
 def refresh_files():
-    return {
-        'name': 'workspace',
-        'children': [
-            {'name': 'hello_world.py', 'children': []},
-        ],
-    }
+    return ['hello_world.py']
 
 
 if __name__ == '__main__':