diff --git a/.github/.codecov.yml b/.github/.codecov.yml index 106704aa0b8b..7a297361d0f1 100644 --- a/.github/.codecov.yml +++ b/.github/.codecov.yml @@ -12,3 +12,5 @@ coverage: threshold: 5% # allow project coverage to drop at most 5% comment: false +github_checks: + annotations: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 51491a52b6f1..5b87df069c77 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,8 +8,8 @@ updates: - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: - interval: "weekly" + interval: "daily" - package-ecosystem: "npm" # See documentation for possible values directory: "/frontend" # Location of package manifests schedule: - interval: "weekly" + interval: "daily" diff --git a/frontend/package.json b/frontend/package.json index 0d3695a4dcd8..a233a70deee0 100644 --- a/frontend/package.json +++ b/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", diff --git a/frontend/src/components/CodeEditor.tsx b/frontend/src/components/CodeEditor.tsx index 4e95b733da6e..ee7e0f551b95 100644 --- a/frontend/src/components/CodeEditor.tsx +++ b/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 (
- +
{ afterEach(() => { @@ -19,25 +10,21 @@ describe("ExplorerTree", () => { }); it("should render the explorer", () => { - const { getByText, queryByText } = render( - , + const { getByText } = renderWithProviders( + , ); - 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( - , - ); + const { queryByText } = renderWithProviders(); - 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"); diff --git a/frontend/src/components/file-explorer/ExplorerTree.tsx b/frontend/src/components/file-explorer/ExplorerTree.tsx index 59778fa7e3f1..ceb37f05eb86 100644 --- a/frontend/src/components/file-explorer/ExplorerTree.tsx +++ b/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 (
- + {files.map((file) => ( + + ))}
); } diff --git a/frontend/src/components/file-explorer/FileExplorer.test.tsx b/frontend/src/components/file-explorer/FileExplorer.test.tsx index 00029f55c2d0..274e16e0814e 100644 --- a/frontend/src/components/file-explorer/FileExplorer.test.tsx +++ b/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(); + const { getByText } = renderWithProviders(); - 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( - , - ); - + it.only("should refetch the workspace when clicking the refresh button", async () => { + const { getByText } = renderWithProviders(); 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(); + 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( - , + const { getByTestId, getByText, queryByText } = renderWithProviders( + , ); 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(); + const { getByTestId } = renderWithProviders(); 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(); + const { getByTestId } = renderWithProviders(); const uploadFileInput = getByTestId("file-input"); const file = new File([""], "test"); diff --git a/frontend/src/components/file-explorer/FileExplorer.tsx b/frontend/src/components/file-explorer/FileExplorer.tsx index 2b0414672dfd..5202186b335a 100644 --- a/frontend/src/components/file-explorer/FileExplorer.tsx +++ b/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(); +function FileExplorer() { const [isHidden, setIsHidden] = React.useState(false); const [isDragging, setIsDragging] = React.useState(false); - + const [files, setFiles] = React.useState([]); const fileInputRef = React.useRef(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) { >
- {workspace && ( - - )} +
setIsHidden((prev) => !prev)} - onRefresh={getWorkspaceData} + onRefresh={refreshWorkspace} onUpload={selectFileInput} />
diff --git a/frontend/src/components/file-explorer/TreeNode.test.tsx b/frontend/src/components/file-explorer/TreeNode.test.tsx index 83a1d48070f3..5172d6890370 100644 --- a/frontend/src/components/file-explorer/TreeNode.test.tsx +++ b/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( - , + const { getByText } = renderWithProviders( + , ); expect(getByText("file.ts")).toBeInTheDocument(); }); - it("should render a folder if property has children", () => { - const { getByText } = render( - , + it("should render a folder if it's in a subdir", async () => { + const { findByText } = renderWithProviders( + , ); + 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( - , + it("should close a folder when clicking on it", async () => { + const { findByText, queryByText } = renderWithProviders( + , ); - 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( - , + it("should open a folder when clicking on it", async () => { + const { getByText, findByText, queryByText } = renderWithProviders( + , ); + 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( - , + it.only("should call a fn and return the full path of a file when clicking on it", () => { + const { getByText } = renderWithProviders( + , ); - 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( - , + it("should render the explorer given the defaultOpen prop", async () => { + const { getByText, findByText, queryByText } = renderWithProviders( + , ); - 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( - , + it("should render all children as collapsed when defaultOpen is false", async () => { + const { findByText, getByText, queryByText } = renderWithProviders( + , ); - 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", - ); }); diff --git a/frontend/src/components/file-explorer/TreeNode.tsx b/frontend/src/components/file-explorer/TreeNode.tsx index a285dc79e129..9852003642e2 100644 --- a/frontend/src/components/file-explorer/TreeNode.tsx +++ b/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(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 (
- {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> )} diff --git a/frontend/src/components/file-explorer/utils.test.ts b/frontend/src/components/file-explorer/utils.test.ts deleted file mode 100644 index fd51731dad67..000000000000 --- a/frontend/src/components/file-explorer/utils.test.ts +++ /dev/null @@ -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", - }, - ], - }, - ], - }); -}); diff --git a/frontend/src/components/file-explorer/utils.ts b/frontend/src/components/file-explorer/utils.ts deleted file mode 100644 index 08060ff3f758..000000000000 --- a/frontend/src/components/file-explorer/utils.ts +++ /dev/null @@ -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; -}; diff --git a/frontend/src/services/actions.ts b/frontend/src/services/actions.ts index 4603eaa3ae1a..83c4fd78eb66 100644 --- a/frontend/src/services/actions.ts +++ b/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)); diff --git a/frontend/src/services/fileService.ts b/frontend/src/services/fileService.ts index 17fe10ec7e5f..21e8f7f054d2 100644 --- a/frontend/src/services/fileService.ts +++ b/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[]; } diff --git a/frontend/src/state/codeSlice.ts b/frontend/src/state/codeSlice.ts index 9e8af5e2030d..3f5dd6571a5a 100644 --- a/frontend/src/state/codeSlice.ts +++ b/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; diff --git a/frontend/src/types/Message.tsx b/frontend/src/types/Message.tsx index 1ff375e9050a..3d3c5e25cde7 100644 --- a/frontend/src/types/Message.tsx +++ b/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; diff --git a/opendevin/runtime/docker/ssh_box.py b/opendevin/runtime/docker/ssh_box.py index 9d7e0cc3dc5d..dd50c38ad322 100644 --- a/opendevin/runtime/docker/ssh_box.py +++ b/opendevin/runtime/docker/ssh_box.py @@ -253,7 +253,13 @@ def __init__( raise e time.sleep(5) self.setup_user() - self.start_ssh_session() + + try: + self.start_ssh_session() + except pxssh.ExceptionPxssh as e: + self.close() + raise e + # make sure /tmp always exists self.execute('mkdir -p /tmp') # set git config diff --git a/opendevin/server/listen.py b/opendevin/server/listen.py index 71911282fded..cf21cbfa2deb 100644 --- a/opendevin/server/listen.py +++ b/opendevin/server/listen.py @@ -1,3 +1,4 @@ +import os import shutil import uuid import warnings @@ -6,7 +7,7 @@ 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.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') diff --git a/opendevin/server/mock/listen.py b/opendevin/server/mock/listen.py index eb3752b0a444..5a4d36a13d83 100644 --- a/opendevin/server/mock/listen.py +++ b/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__': diff --git a/opendevin/server/session/msg_stack.py b/opendevin/server/session/msg_stack.py index 0c8a64d0e806..6e0862af4426 100644 --- a/opendevin/server/session/msg_stack.py +++ b/opendevin/server/session/msg_stack.py @@ -99,7 +99,7 @@ async def _del_messages(self, del_sid: str): new_data = {} for sid, msgs in data.items(): if sid != del_sid: - new_data[sid] = [Message.from_dict(msg) for msg in msgs] + new_data[sid] = msgs # Move the file pointer to the beginning of the file to overwrite the original contents file.seek(0) # clean previous content