feat: make use of the partial file tree fetching implemented in the backend

This commit is contained in:
Alex Bäuerle 2024-04-18 16:05:06 -07:00
parent fe3d4b129d
commit c29a25658c
No known key found for this signature in database
GPG Key ID: EB015650BB01959A
6 changed files with 134 additions and 139 deletions

View File

@ -1,8 +1,8 @@
import { Accordion, AccordionItem } from "@nextui-org/react";
import React, { useEffect } from "react";
import TreeView, {
ITreeViewOnExpandProps,
ITreeViewOnNodeSelectProps,
flattenTree,
} from "react-accessible-treeview";
import { AiOutlineFolder } from "react-icons/ai";
@ -14,8 +14,13 @@ import {
} from "react-icons/io";
import { useDispatch, useSelector } from "react-redux";
import { getWorkspace, selectFile } from "../services/fileService";
import { setCode, updateWorkspace } from "../state/codeSlice";
import { getWorkspaceDepthOne, selectFile } from "../services/fileService";
import {
pruneWorkspace,
resetWorkspace,
setCode,
updateWorkspace,
} from "../state/codeSlice";
import { RootState } from "../store";
import FileIcon from "./FileIcons";
import FolderIcon from "./FolderIcon";
@ -69,15 +74,14 @@ function Files({
explorerOpen,
}: FilesProps): JSX.Element {
const dispatch = useDispatch();
const workspaceFolder = useSelector(
const workspaceTree = useSelector(
(state: RootState) => state.code.workspaceFolder,
);
const selectedIds = useSelector((state: RootState) => state.code.selectedIds);
const workspaceTree = flattenTree(workspaceFolder);
useEffect(() => {
getWorkspace().then((file) => dispatch(updateWorkspace(file)));
getWorkspaceDepthOne("").then((file) => dispatch(updateWorkspace(file)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -102,7 +106,7 @@ function Files({
}
const handleNodeSelect = (node: ITreeViewOnNodeSelectProps) => {
if (!node.isBranch) {
if (!node.element.isBranch) {
let fullPath = node.element.name;
setSelectedFileName(fullPath);
let currentNode = workspaceTree.find(
@ -120,6 +124,23 @@ function Files({
}
};
const handleNodeExpand = (node: ITreeViewOnExpandProps) => {
if (node.isExpanded) {
const currentNode = workspaceTree.find(
(treeNode) => treeNode.id === node.element.id,
);
if (!currentNode) return;
getWorkspaceDepthOne(currentNode.relativePath).then((files) => {
dispatch(updateWorkspace(files));
});
} else {
const currentNode = workspaceTree.find(
(treeNode) => treeNode.id === node.element.id,
);
dispatch(pruneWorkspace(currentNode));
}
};
return (
<div className="bg-neutral-800 min-w-[228px] h-full border-r-1 border-r-neutral-600 flex flex-col transition-all ease-soft-spring">
<div className="flex p-2 items-center justify-between relative">
@ -131,12 +152,10 @@ function Files({
}}
hideIndicator
key="1"
aria-label={workspaceFolder.name}
aria-label=""
title={
<div className="group flex items-center justify-between">
<span className="text-neutral-400 text-sm">
{workspaceFolder.name}
</span>
<span className="text-neutral-400 text-sm" />
</div>
}
className="editor-accordion"
@ -152,8 +171,11 @@ function Files({
className="text-sm text-neutral-400"
data={workspaceTree}
selectedIds={selectedIds}
expandedIds={workspaceTree.map((node) => node.id)}
expandedIds={workspaceTree
.filter((node) => node.children.length > 0)
.map((node) => node.id)}
onNodeSelect={handleNodeSelect}
onExpand={handleNodeExpand}
// eslint-disable-next-line react/no-unstable-nested-components
nodeRenderer={({
element,
@ -184,9 +206,12 @@ function Files({
</Accordion>
<div className="transform flex h-[24px] items-center gap-1 absolute top-2 right-2">
<RefreshButton
onClick={() =>
getWorkspace().then((file) => dispatch(updateWorkspace(file)))
}
onClick={() => {
dispatch(resetWorkspace());
getWorkspaceDepthOne("").then((file) =>
dispatch(updateWorkspace(file)),
);
}}
ariaLabel="Refresh"
/>
<CloseButton

View File

@ -1,14 +1,14 @@
import { changeTaskState } from "../state/agentSlice";
import { setScreenshotSrc, setUrl } from "../state/browserSlice";
import { appendAssistantMessage } from "../state/chatSlice";
import { setCode, updatePath } from "../state/codeSlice";
import { setCode } from "../state/codeSlice";
import { appendInput } from "../state/commandSlice";
import { setInitialized } from "../state/taskSlice";
import store from "../store";
import ActionType from "../types/ActionType";
import { ActionMessage } from "../types/Message";
import { SocketMessage } from "../types/ResponseType";
import { handleObservationMessage } from "./observations";
import ActionType from "../types/ActionType";
import { changeTaskState } from "../state/agentSlice";
const messageActions = {
[ActionType.INIT]: () => {
@ -20,8 +20,7 @@ const messageActions = {
store.dispatch(setScreenshotSrc(screenshotSrc));
},
[ActionType.WRITE]: (message: ActionMessage) => {
const { path, content } = message.args;
store.dispatch(updatePath(path));
const { content } = message.args;
store.dispatch(setCode(content));
},
[ActionType.THINK]: (message: ActionMessage) => {

View File

@ -12,12 +12,6 @@ export async function selectFile(file: string): Promise<string> {
return data.code as string;
}
export async function getWorkspace(): Promise<WorkspaceFile> {
const res = await fetch("/api/refresh-files");
const data = await res.json();
return data as WorkspaceFile;
}
export type WorkspaceItem = {
name: string;
isBranch: boolean;

View File

@ -1,69 +1,75 @@
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";
import { WorkspaceItem } from "../services/fileService";
function addItemsToWorkspace(
items: WorkspaceItem[],
workspace: WorkspaceItem[],
): WorkspaceItem[] {
const filteredItems = items.filter(
(item) => !workspace.find((file) => file.id === item.id),
);
if (filteredItems.length === 0) {
return workspace;
}
for (const item of filteredItems) {
const parentItem = workspace.find((file) => file.id === item.parent);
if (!parentItem) {
return workspace;
}
parentItem.children = [...parentItem.children, item.id];
}
return [...workspace, ...filteredItems];
}
export const codeSlice = createSlice({
name: "code",
initialState: {
code: "# Welcome to OpenDevin!",
selectedIds: [] as number[],
workspaceFolder: { name: "" } as WorkspaceFile,
selectedIds: [] as string[],
workspaceFolder: [
{
name: "",
children: [],
isBranch: true,
relativePath: "",
id: "root",
parent: null,
},
] as WorkspaceItem[],
},
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;
resetWorkspace: (state) => {
state.workspaceFolder = [
{
name: "",
children: [],
isBranch: true,
relativePath: "",
id: "root",
parent: null,
},
];
},
updateWorkspace: (state, action) => {
state.workspaceFolder = action.payload;
state.workspaceFolder = addItemsToWorkspace(
action.payload as WorkspaceItem[],
state.workspaceFolder,
);
},
pruneWorkspace: (state, action) => {
const item = action.payload as WorkspaceItem;
state.workspaceFolder = [
...state.workspaceFolder.filter((file) => !file.id.includes(item.id)),
{ ...item, children: [] },
];
},
},
});
export const { setCode, updatePath, updateWorkspace } = codeSlice.actions;
export const { setCode, updateWorkspace, pruneWorkspace, resetWorkspace } =
codeSlice.actions;
export default codeSlice.reducer;

View File

@ -1,48 +1,9 @@
from pathlib import Path
from typing import Any, Dict, List
from typing import List
from pydantic import BaseModel
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
class WorkspaceItem(BaseModel):
name: str
isBranch: bool
@ -52,18 +13,28 @@ class WorkspaceItem(BaseModel):
children: List['WorkspaceItem'] = []
def get_single_level_folder_structure(base_path: Path, workdir: Path) -> List[WorkspaceItem]:
def get_single_level_folder_structure(
base_path: Path, workdir: Path
) -> List[WorkspaceItem]:
"""Generate a list of files and directories at the current level with type indicator, relative paths, and tree metadata."""
entries = []
entries: List[WorkspaceItem] = []
if not workdir.is_dir():
return entries
for item in workdir.iterdir():
item_relative_path = item.relative_to(base_path).as_posix()
# Using the relative path as an 'id' ensuring uniqueness within the workspace context
parent_path = workdir.relative_to(base_path).as_posix() if workdir != base_path else 'root'
entries.append(WorkspaceItem(
name=item.name,
isBranch=item.is_dir(),
relativePath=item_relative_path,
id=item_relative_path,
parent=parent_path
))
parent_path = (
workdir.relative_to(base_path).as_posix()
if workdir != base_path
else 'root'
)
entries.append(
WorkspaceItem(
name=item.name,
isBranch=item.is_dir(),
relativePath=item_relative_path,
id=item_relative_path,
parent=parent_path,
)
)
return entries

View File

@ -2,11 +2,11 @@ import uuid
from pathlib import Path
import litellm
from fastapi import Depends, FastAPI, WebSocket, HTTPException, Query, status
from fastapi import Depends, FastAPI, HTTPException, WebSocket, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse, JSONResponse
import agenthub # noqa F401 (we import this to get the agents registered)
from opendevin import config, files
@ -72,7 +72,9 @@ async def get_token(
sid = get_sid_from_token(credentials.credentials)
if not sid:
sid = str(uuid.uuid4())
logger.info(f'Invalid or missing credentials, generating new session ID: {sid}')
logger.info(
f'Invalid or missing credentials, generating new session ID: {sid}'
)
else:
sid = str(uuid.uuid4())
logger.info(f'No credentials provided, generating new session ID: {sid}')
@ -110,24 +112,22 @@ async def del_messages(
return {'ok': True}
@app.get('/api/refresh-files')
def refresh_files():
structure = files.get_folder_structure(Path(str(config.get('WORKSPACE_BASE'))))
return structure.to_dict()
@app.get('/api/list-files')
def list_files(relpath: str = Query(None, description='Relative path from workspace base')):
def list_files(relpath: str):
"""Refreshes and returns the files and directories from a specified subdirectory or the base directory if no subdirectory is specified, limited to one level deep."""
base_path = Path(config.get('WORKSPACE_BASE')).resolve()
full_path = (base_path / relpath).resolve() if relpath is not None else base_path
full_path = (base_path / relpath).resolve() if relpath != '' else base_path
logger.debug(f'Listing files at {full_path}')
# Ensure path exists, is a directory,
# And is within the workspace base directory - to prevent directory traversal attacks
# https://owasp.org/www-community/attacks/Path_Traversal
if not full_path.exists() or not full_path.is_dir() or not str(full_path).startswith(str(base_path)):
if (
not full_path.exists()
or not full_path.is_dir()
or not str(full_path).startswith(str(base_path))
):
raise HTTPException(status_code=400, detail='Invalid path provided.')
structure = files.get_single_level_folder_structure(base_path, full_path)