Remove file editing functionality from UI (#5823)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Robert Brennan
2024-12-26 18:02:38 -05:00
committed by GitHub
parent f1a8be3817
commit b72f50cc4a
13 changed files with 78 additions and 510 deletions

View File

@@ -1,115 +0,0 @@
import { Editor, EditorProps } from "@monaco-editor/react";
import React from "react";
import { useTranslation } from "react-i18next";
import { VscCode } from "react-icons/vsc";
import { I18nKey } from "#/i18n/declaration";
import { useFiles } from "#/context/files";
import { useSaveFile } from "#/hooks/mutation/use-save-file";
interface CodeEditorComponentProps {
onMount: EditorProps["onMount"];
isReadOnly: boolean;
}
function CodeEditorComponent({
onMount,
isReadOnly,
}: CodeEditorComponentProps) {
const { t } = useTranslation();
const {
files,
selectedPath,
modifiedFiles,
modifyFileContent,
saveFileContent: saveNewFileContent,
} = useFiles();
const { mutate: saveFile } = useSaveFile();
const handleEditorChange = (value: string | undefined) => {
if (selectedPath && value) modifyFileContent(selectedPath, value);
};
const isBase64Image = (content: string) => content.startsWith("data:image/");
const isPDF = (content: string) => content.startsWith("data:application/pdf");
const isVideo = (content: string) => content.startsWith("data:video/");
React.useEffect(() => {
const handleSave = async (event: KeyboardEvent) => {
if (selectedPath && event.metaKey && event.key === "s") {
const content = saveNewFileContent(selectedPath);
if (content) {
saveFile({ path: selectedPath, content });
}
}
};
document.addEventListener("keydown", handleSave);
return () => {
document.removeEventListener("keydown", handleSave);
};
}, [saveNewFileContent]);
if (!selectedPath) {
return (
<div
data-testid="code-editor-empty-message"
className="flex flex-col h-full items-center justify-center text-neutral-400"
>
<VscCode size={100} />
{t(I18nKey.CODE_EDITOR$EMPTY_MESSAGE)}
</div>
);
}
const fileContent: string | undefined =
modifiedFiles[selectedPath] || files[selectedPath];
if (fileContent) {
if (isBase64Image(fileContent)) {
return (
<section className="flex flex-col relative items-center overflow-auto h-[90%]">
<img
src={fileContent}
alt={selectedPath}
className="object-contain"
/>
</section>
);
}
if (isPDF(fileContent)) {
return (
<iframe
src={fileContent}
title={selectedPath}
width="100%"
height="100%"
/>
);
}
if (isVideo(fileContent)) {
return (
<video controls src={fileContent} width="100%" height="100%">
<track kind="captions" label="English captions" />
</video>
);
}
}
return (
<Editor
data-testid="code-editor"
path={selectedPath ?? undefined}
defaultValue=""
value={selectedPath ? fileContent : undefined}
onMount={onMount}
onChange={handleEditorChange}
options={{ readOnly: isReadOnly }}
/>
);
}
export default React.memo(CodeEditorComponent);

View File

@@ -1,33 +0,0 @@
import { EditorActionButton } from "#/components/shared/buttons/editor-action-button";
interface EditorActionsProps {
onSave: () => void;
onDiscard: () => void;
isDisabled: boolean;
}
export function EditorActions({
onSave,
onDiscard,
isDisabled,
}: EditorActionsProps) {
return (
<div className="flex gap-2">
<EditorActionButton
onClick={onSave}
disabled={isDisabled}
className="bg-neutral-800 disabled:hover:bg-neutral-800"
>
Save
</EditorActionButton>
<EditorActionButton
onClick={onDiscard}
disabled={isDisabled}
className="border border-neutral-800 disabled:hover:bg-transparent"
>
Discard
</EditorActionButton>
</div>
);
}

View File

@@ -1,27 +0,0 @@
import { useTranslation } from "react-i18next";
import { IoFileTray } from "react-icons/io5";
import { I18nKey } from "#/i18n/declaration";
interface DropzoneProps {
onDragLeave: () => void;
onDrop: (event: React.DragEvent<HTMLDivElement>) => void;
}
export function Dropzone({ onDragLeave, onDrop }: DropzoneProps) {
const { t } = useTranslation();
return (
<div
data-testid="dropzone"
onDragLeave={onDragLeave}
onDrop={onDrop}
onDragOver={(event) => event.preventDefault()}
className="z-10 absolute flex flex-col justify-center items-center bg-black top-0 bottom-0 left-0 right-0 opacity-65"
>
<IoFileTray size={32} />
<p className="font-bold text-xl">
{t(I18nKey.EXPLORER$LABEL_DROP_FILES)}
</p>
</div>
);
}

View File

@@ -1,11 +1,9 @@
import { RefreshIconButton } from "#/components/shared/buttons/refresh-icon-button";
import { ToggleWorkspaceIconButton } from "#/components/shared/buttons/toggle-workspace-icon-button";
import { UploadIconButton } from "#/components/shared/buttons/upload-icon-button";
import { cn } from "#/utils/utils";
interface ExplorerActionsProps {
onRefresh: () => void;
onUpload: () => void;
toggleHidden: () => void;
isHidden: boolean;
}
@@ -13,7 +11,6 @@ interface ExplorerActionsProps {
export function ExplorerActions({
toggleHidden,
onRefresh,
onUpload,
isHidden,
}: ExplorerActionsProps) {
return (
@@ -23,12 +20,7 @@ export function ExplorerActions({
isHidden ? "right-3" : "right-2",
)}
>
{!isHidden && (
<>
<RefreshIconButton onClick={onRefresh} />
<UploadIconButton onClick={onUpload} />
</>
)}
{!isHidden && <RefreshIconButton onClick={onRefresh} />}
<ToggleWorkspaceIconButton isHidden={isHidden} onClick={toggleHidden} />
</div>

View File

@@ -7,14 +7,12 @@ interface FileExplorerHeaderProps {
isOpen: boolean;
onToggle: () => void;
onRefreshWorkspace: () => void;
onUploadFile: () => void;
}
export function FileExplorerHeader({
isOpen,
onToggle,
onRefreshWorkspace,
onUploadFile,
}: FileExplorerHeaderProps) {
const { t } = useTranslation();
@@ -35,7 +33,6 @@ export function FileExplorerHeader({
isHidden={!isOpen}
toggleHidden={onToggle}
onRefresh={onRefreshWorkspace}
onUpload={onUploadFile}
/>
</div>
);

View File

@@ -1,5 +1,5 @@
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { ExplorerTree } from "#/components/features/file-explorer/explorer-tree";
@@ -7,14 +7,10 @@ import toast from "#/utils/toast";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { useListFiles } from "#/hooks/query/use-list-files";
import { FileUploadSuccessResponse } from "#/api/open-hands.types";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { cn } from "#/utils/utils";
import { Dropzone } from "./dropzone";
import { FileExplorerHeader } from "./file-explorer-header";
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
import { OpenVSCodeButton } from "#/components/shared/buttons/open-vscode-button";
import { addAssistantMessage } from "#/state/chat-slice";
interface FileExplorerProps {
isOpen: boolean;
@@ -23,26 +19,16 @@ interface FileExplorerProps {
export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { data: paths, refetch, error } = useListFiles();
const { mutate: uploadFiles } = useUploadFiles();
const { data: vscodeUrl } = useVSCodeUrl({
enabled: !RUNTIME_INACTIVE_STATES.includes(curAgentState),
});
const handleOpenVSCode = () => {
if (vscodeUrl?.vscode_url) {
dispatch(
addAssistantMessage(
"You opened VS Code. Please inform the agent of any changes you made to the workspace or environment. To avoid conflicts, it's best to pause the agent before making any changes.",
),
);
window.open(vscodeUrl.vscode_url, "_blank");
} else if (vscodeUrl?.error) {
toast.error(
@@ -54,86 +40,18 @@ export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
}
};
const selectFileInput = () => {
fileInputRef.current?.click(); // Trigger the file browser
};
const handleUploadSuccess = (data: FileUploadSuccessResponse) => {
const uploadedCount = data.uploaded_files.length;
const skippedCount = data.skipped_files.length;
if (uploadedCount > 0) {
toast.success(
`upload-success-${new Date().getTime()}`,
t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, {
count: uploadedCount,
}),
);
}
if (skippedCount > 0) {
const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, {
count: skippedCount,
});
toast.info(message);
}
if (uploadedCount === 0 && skippedCount === 0) {
toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE));
}
};
const handleUploadError = (uploadError: Error) => {
toast.error(
`upload-error-${new Date().getTime()}`,
uploadError.message || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
);
};
const refreshWorkspace = () => {
if (!RUNTIME_INACTIVE_STATES.includes(curAgentState)) {
refetch();
}
};
const uploadFileData = (files: FileList) => {
uploadFiles(
{ files: Array.from(files) },
{ onSuccess: handleUploadSuccess, onError: handleUploadError },
);
refreshWorkspace();
};
const handleDropFiles = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
const { files: droppedFiles } = event.dataTransfer;
if (droppedFiles.length > 0) {
uploadFileData(droppedFiles);
}
setIsDragging(false);
};
React.useEffect(() => {
refreshWorkspace();
}, [curAgentState]);
return (
<div
data-testid="file-explorer"
className="relative h-full"
onDragEnter={() => {
setIsDragging(true);
}}
onDragEnd={() => {
setIsDragging(false);
}}
>
{isDragging && (
<Dropzone
onDragLeave={() => setIsDragging(false)}
onDrop={handleDropFiles}
/>
)}
<div data-testid="file-explorer" className="relative h-full">
<div
className={cn(
"bg-neutral-800 h-full border-r-1 border-r-neutral-600 flex flex-col",
@@ -145,7 +63,6 @@ export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
isOpen={isOpen}
onToggle={onToggle}
onRefreshWorkspace={refreshWorkspace}
onUploadFile={selectFileInput}
/>
{!error && (
<div className="overflow-auto flex-grow min-h-0">

View File

@@ -14,13 +14,7 @@ interface TreeNodeProps {
}
function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
const {
setFileContent,
modifiedFiles,
setSelectedPath,
files,
selectedPath,
} = useFiles();
const { setFileContent, setSelectedPath, files, selectedPath } = useFiles();
const [isOpen, setIsOpen] = React.useState(defaultOpen);
const { curAgentState } = useSelector((state: RootState) => state.agent);
@@ -35,8 +29,7 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
React.useEffect(() => {
if (fileContent) {
const code = modifiedFiles[path] || files[path];
if (!code || fileContent !== files[path]) {
if (fileContent !== files[path]) {
setFileContent(path, fileContent);
}
}
@@ -79,10 +72,6 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
type={isDirectory ? "folder" : "file"}
isOpen={isOpen}
/>
{modifiedFiles[path] && (
<div className="w-2 h-2 rounded-full bg-neutral-500" />
)}
</button>
{isOpen && paths && (

View File

@@ -1,22 +0,0 @@
import { IoIosCloudUpload } from "react-icons/io";
import { IconButton } from "./icon-button";
interface UploadIconButtonProps {
onClick: () => void;
}
export function UploadIconButton({ onClick }: UploadIconButtonProps) {
return (
<IconButton
icon={
<IoIosCloudUpload
size={16}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
}
testId="upload"
ariaLabel="Upload File"
onClick={onClick}
/>
);
}

View File

@@ -24,10 +24,6 @@ interface FilesContextType {
setFileContent: (path: string, content: string) => void;
selectedPath: string | null;
setSelectedPath: (path: string | null) => void;
modifiedFiles: Record<string, string>;
modifyFileContent: (path: string, content: string) => void;
saveFileContent: (path: string) => string | undefined;
discardChanges: (path: string) => void;
}
const FilesContext = React.createContext<FilesContextType | undefined>(
@@ -41,49 +37,12 @@ interface FilesProviderProps {
function FilesProvider({ children }: FilesProviderProps) {
const [paths, setPaths] = React.useState<string[]>([]);
const [files, setFiles] = React.useState<Record<string, string>>({});
const [modifiedFiles, setModifiedFiles] = React.useState<
Record<string, string>
>({});
const [selectedPath, setSelectedPath] = React.useState<string | null>(null);
const setFileContent = React.useCallback((path: string, content: string) => {
setFiles((prev) => ({ ...prev, [path]: content }));
}, []);
const modifyFileContent = React.useCallback(
(path: string, content: string) => {
if (files[path] !== content) {
setModifiedFiles((prev) => ({ ...prev, [path]: content }));
} else {
const newModifiedFiles = { ...modifiedFiles };
delete newModifiedFiles[path];
setModifiedFiles(newModifiedFiles);
}
},
[files, modifiedFiles],
);
const discardChanges = React.useCallback((path: string) => {
setModifiedFiles((prev) => {
const newModifiedFiles = { ...prev };
delete newModifiedFiles[path];
return newModifiedFiles;
});
}, []);
const saveFileContent = React.useCallback(
(path: string): string | undefined => {
const content = modifiedFiles[path];
if (content) {
setFiles((prev) => ({ ...prev, [path]: content }));
discardChanges(path);
}
return content;
},
[files, modifiedFiles, selectedPath, discardChanges],
);
const value = React.useMemo(
() => ({
paths,
@@ -92,23 +51,8 @@ function FilesProvider({ children }: FilesProviderProps) {
setFileContent,
selectedPath,
setSelectedPath,
modifiedFiles,
modifyFileContent,
saveFileContent,
discardChanges,
}),
[
paths,
setPaths,
files,
setFileContent,
selectedPath,
setSelectedPath,
modifiedFiles,
modifyFileContent,
saveFileContent,
discardChanges,
],
[paths, setPaths, files, setFileContent, selectedPath, setSelectedPath],
);
return (

View File

@@ -1,20 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import toast from "react-hot-toast";
import OpenHands from "#/api/open-hands";
import { useConversation } from "#/context/conversation-context";
type SaveFileArgs = {
path: string;
content: string;
};
export const useSaveFile = () => {
const { conversationId } = useConversation();
return useMutation({
mutationFn: ({ path, content }: SaveFileArgs) =>
OpenHands.saveFile(conversationId, path, content),
onError: (error) => {
toast.error(error.message);
},
});
};

View File

@@ -1,15 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useConversation } from "#/context/conversation-context";
type UploadFilesArgs = {
files: File[];
};
export const useUploadFiles = () => {
const { conversationId } = useConversation();
return useMutation({
mutationFn: ({ files }: UploadFilesArgs) =>
OpenHands.uploadFiles(conversationId, files),
});
};

View File

@@ -1,16 +1,9 @@
import React from "react";
import { useSelector } from "react-redux";
import { useRouteError } from "react-router";
import { editor } from "monaco-editor";
import { EditorProps } from "@monaco-editor/react";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
import CodeEditorComponent from "../../components/features/editor/code-editor-component";
import { useFiles } from "#/context/files";
import { useSaveFile } from "#/hooks/mutation/use-save-file";
import { ASSET_FILE_TYPES } from "./constants";
import { EditorActions } from "#/components/features/editor/editor-actions";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
import { FileExplorer } from "#/components/features/file-explorer/file-explorer";
import { useFiles } from "#/context/files";
export function ErrorBoundary() {
const error = useRouteError();
@@ -23,90 +16,91 @@ export function ErrorBoundary() {
);
}
function CodeEditor() {
const {
selectedPath,
modifiedFiles,
saveFileContent: saveNewFileContent,
discardChanges,
} = useFiles();
function getLanguageFromPath(path: string): string {
const extension = path.split(".").pop()?.toLowerCase();
switch (extension) {
case "js":
case "jsx":
return "javascript";
case "ts":
case "tsx":
return "typescript";
case "py":
return "python";
case "html":
return "html";
case "css":
return "css";
case "json":
return "json";
case "md":
return "markdown";
case "yml":
case "yaml":
return "yaml";
case "sh":
case "bash":
return "bash";
case "dockerfile":
return "dockerfile";
case "rs":
return "rust";
case "go":
return "go";
case "java":
return "java";
case "cpp":
case "cc":
case "cxx":
return "cpp";
case "c":
return "c";
case "rb":
return "ruby";
case "php":
return "php";
case "sql":
return "sql";
default:
return "text";
}
}
function FileViewer() {
const [fileExplorerIsOpen, setFileExplorerIsOpen] = React.useState(true);
const editorRef = React.useRef<editor.IStandaloneCodeEditor | null>(null);
const { mutate: saveFile } = useSaveFile();
const { selectedPath, files } = useFiles();
const toggleFileExplorer = () => {
setFileExplorerIsOpen((prev) => !prev);
editorRef.current?.layout({ width: 0, height: 0 });
};
const handleEditorDidMount: EditorProps["onMount"] = (e, monaco) => {
editorRef.current = e;
monaco.editor.defineTheme("oh-dark", {
base: "vs-dark",
inherit: true,
rules: [],
colors: {
"editor.background": "#171717",
},
});
monaco.editor.setTheme("oh-dark");
};
const agentState = useSelector(
(state: RootState) => state.agent.curAgentState,
);
// Code editing is only allowed when the agent is paused, finished, or awaiting user input (server rules)
const isEditingAllowed = React.useMemo(
() =>
agentState === AgentState.PAUSED ||
agentState === AgentState.FINISHED ||
agentState === AgentState.AWAITING_USER_INPUT,
[agentState],
);
const handleSave = async () => {
if (selectedPath) {
const content = modifiedFiles[selectedPath];
if (content) {
saveFile({ path: selectedPath, content });
saveNewFileContent(selectedPath);
}
}
};
const handleDiscard = () => {
if (selectedPath) discardChanges(selectedPath);
};
const isAssetFileType = selectedPath
? ASSET_FILE_TYPES.some((ext) => selectedPath.endsWith(ext))
: false;
return (
<div className="flex h-full bg-neutral-900 relative">
<FileExplorer isOpen={fileExplorerIsOpen} onToggle={toggleFileExplorer} />
<div className="w-full">
{selectedPath && !isAssetFileType && (
{selectedPath && (
<div className="flex w-full items-center justify-between self-end p-2">
<span className="text-sm text-neutral-500">{selectedPath}</span>
<EditorActions
onSave={handleSave}
onDiscard={handleDiscard}
isDisabled={!isEditingAllowed || !modifiedFiles[selectedPath]}
/>
</div>
)}
<CodeEditorComponent
onMount={handleEditorDidMount}
isReadOnly={!isEditingAllowed}
/>
{selectedPath && files[selectedPath] && (
<div className="p-4">
<SyntaxHighlighter
language={getLanguageFromPath(selectedPath)}
style={vscDarkPlus}
customStyle={{
margin: 0,
background: "#171717",
fontSize: "0.875rem",
}}
>
{files[selectedPath]}
</SyntaxHighlighter>
</div>
)}
</div>
</div>
);
}
export default CodeEditor;
export default FileViewer;

View File

@@ -1,13 +1,9 @@
import React from "react";
import toast from "react-hot-toast";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { useAuth } from "#/context/auth-context";
import { useWsClient } from "#/context/ws-client-provider";
import { getGitHubTokenCommand } from "#/services/terminal-service";
import { setImportedProjectZip } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { base64ToBlob } from "#/utils/base64-to-blob";
import { useUploadFiles } from "../../../hooks/mutation/use-upload-files";
import { useGitHubUser } from "../../../hooks/query/use-github-user";
import { isGitHubErrorReponse } from "#/api/github-axios-instance";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
@@ -17,48 +13,19 @@ export const useHandleRuntimeActive = () => {
const { send } = useWsClient();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const dispatch = useDispatch();
const { data: user } = useGitHubUser();
const { mutate: uploadFiles } = useUploadFiles();
const runtimeActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
const { importedProjectZip } = useSelector(
(state: RootState) => state.initialQuery,
);
const userId = React.useMemo(() => {
if (user && !isGitHubErrorReponse(user)) return user.id;
return null;
}, [user]);
const handleUploadFiles = (zip: string) => {
const blob = base64ToBlob(zip);
const file = new File([blob], "imported-project.zip", {
type: blob.type,
});
uploadFiles(
{ files: [file] },
{
onError: () => {
toast.error("Failed to upload project files.");
},
},
);
dispatch(setImportedProjectZip(null));
};
React.useEffect(() => {
if (runtimeActive && userId && gitHubToken) {
// Export if the user valid, this could happen mid-session so it is handled here
send(getGitHubTokenCommand(gitHubToken));
}
}, [userId, gitHubToken, runtimeActive]);
React.useEffect(() => {
if (runtimeActive && importedProjectZip) {
handleUploadFiles(importedProjectZip);
}
}, [runtimeActive, importedProjectZip]);
};