mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat: make use of the partial file tree fetching implemented in the backend
This commit is contained in:
parent
fe3d4b129d
commit
c29a25658c
@ -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
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user