From 5d366129d10c8ac19bcc5c846b03abb2c51d3b05 Mon Sep 17 00:00:00 2001
From: "sp.wack" <83104063+amanape@users.noreply.github.com>
Date: Wed, 27 Nov 2024 09:46:30 +0400
Subject: [PATCH] refactor(frontend): App index route (mainly file explorer)
(#5287)
---
.../file-explorer/file-explorer.test.tsx | 2 +-
.../file-explorer/file-explorer.tsx | 307 ------------------
frontend/src/hooks/query/use-vscode-url.ts | 43 +++
.../src/routes/_oh.app._index/constants.ts | 11 +
.../buttons/open-vscode-button.tsx | 30 ++
.../buttons/refresh-icon-button.tsx | 22 ++
.../buttons/toggle-workspace-icon-button.tsx | 33 ++
.../buttons/upload-icon-button.tsx | 22 ++
.../_oh.app._index/file-explorer/dropzone.tsx | 27 ++
.../file-explorer/file-explorer-actions.tsx | 36 ++
.../file-explorer/file-explorer-header.tsx | 42 +++
.../file-explorer/file-explorer.tsx | 156 +++++++++
frontend/src/routes/_oh.app._index/route.tsx | 15 +-
13 files changed, 425 insertions(+), 321 deletions(-)
delete mode 100644 frontend/src/components/file-explorer/file-explorer.tsx
create mode 100644 frontend/src/hooks/query/use-vscode-url.ts
create mode 100644 frontend/src/routes/_oh.app._index/constants.ts
create mode 100644 frontend/src/routes/_oh.app._index/file-explorer/buttons/open-vscode-button.tsx
create mode 100644 frontend/src/routes/_oh.app._index/file-explorer/buttons/refresh-icon-button.tsx
create mode 100644 frontend/src/routes/_oh.app._index/file-explorer/buttons/toggle-workspace-icon-button.tsx
create mode 100644 frontend/src/routes/_oh.app._index/file-explorer/buttons/upload-icon-button.tsx
create mode 100644 frontend/src/routes/_oh.app._index/file-explorer/dropzone.tsx
create mode 100644 frontend/src/routes/_oh.app._index/file-explorer/file-explorer-actions.tsx
create mode 100644 frontend/src/routes/_oh.app._index/file-explorer/file-explorer-header.tsx
create mode 100644 frontend/src/routes/_oh.app._index/file-explorer/file-explorer.tsx
diff --git a/frontend/__tests__/components/file-explorer/file-explorer.test.tsx b/frontend/__tests__/components/file-explorer/file-explorer.test.tsx
index ce78b2da3ffa..6b360520347d 100644
--- a/frontend/__tests__/components/file-explorer/file-explorer.test.tsx
+++ b/frontend/__tests__/components/file-explorer/file-explorer.test.tsx
@@ -4,7 +4,7 @@ import { renderWithProviders } from "test-utils";
import { describe, it, expect, vi, Mock, afterEach } from "vitest";
import toast from "#/utils/toast";
import AgentState from "#/types/agent-state";
-import FileExplorer from "#/components/file-explorer/file-explorer";
+import { FileExplorer } from "#/routes/_oh.app._index/file-explorer/file-explorer";
import OpenHands from "#/api/open-hands";
const toastSpy = vi.spyOn(toast, "error");
diff --git a/frontend/src/components/file-explorer/file-explorer.tsx b/frontend/src/components/file-explorer/file-explorer.tsx
deleted file mode 100644
index 5c82a7fa8482..000000000000
--- a/frontend/src/components/file-explorer/file-explorer.tsx
+++ /dev/null
@@ -1,307 +0,0 @@
-import React from "react";
-import {
- IoIosArrowBack,
- IoIosArrowForward,
- IoIosRefresh,
- IoIosCloudUpload,
-} from "react-icons/io";
-import { useDispatch, useSelector } from "react-redux";
-import { IoFileTray } from "react-icons/io5";
-import { useTranslation } from "react-i18next";
-import { twMerge } from "tailwind-merge";
-import AgentState from "#/types/agent-state";
-import { addAssistantMessage } from "#/state/chat-slice";
-import IconButton from "../icon-button";
-import ExplorerTree from "./explorer-tree";
-import toast from "#/utils/toast";
-import { RootState } from "#/store";
-import { I18nKey } from "#/i18n/declaration";
-import OpenHands from "#/api/open-hands";
-import VSCodeIcon from "#/assets/vscode-alt.svg?react";
-import { useListFiles } from "#/hooks/query/use-list-files";
-import { FileUploadSuccessResponse } from "#/api/open-hands.types";
-import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
-
-interface ExplorerActionsProps {
- onRefresh: () => void;
- onUpload: () => void;
- toggleHidden: () => void;
- isHidden: boolean;
-}
-
-function ExplorerActions({
- toggleHidden,
- onRefresh,
- onUpload,
- isHidden,
-}: ExplorerActionsProps) {
- return (
-
- {!isHidden && (
- <>
-
- }
- testId="refresh"
- ariaLabel="Refresh workspace"
- onClick={onRefresh}
- />
-
- }
- testId="upload"
- ariaLabel="Upload File"
- onClick={onUpload}
- />
- >
- )}
-
-
- ) : (
-
- )
- }
- testId="toggle"
- ariaLabel={isHidden ? "Open workspace" : "Close workspace"}
- onClick={toggleHidden}
- />
-
- );
-}
-
-interface FileExplorerProps {
- isOpen: boolean;
- onToggle: () => void;
-}
-
-function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
- const [isDragging, setIsDragging] = React.useState(false);
-
- const { curAgentState } = useSelector((state: RootState) => state.agent);
- const fileInputRef = React.useRef(null);
- const dispatch = useDispatch();
- const { t } = useTranslation();
- const selectFileInput = () => {
- fileInputRef.current?.click(); // Trigger the file browser
- };
-
- const { data: paths, refetch, error } = useListFiles();
-
- 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 = (e: Error) => {
- toast.error(
- `upload-error-${new Date().getTime()}`,
- e.message || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
- );
- };
-
- const { mutate: uploadFiles } = useUploadFiles();
-
- const refreshWorkspace = () => {
- if (
- curAgentState !== AgentState.LOADING &&
- curAgentState !== AgentState.STOPPED
- ) {
- refetch();
- }
- };
-
- const uploadFileData = (files: FileList) => {
- uploadFiles(
- { files: Array.from(files) },
- { onSuccess: handleUploadSuccess, onError: handleUploadError },
- );
- refreshWorkspace();
- };
-
- const handleVSCodeClick = async (e: React.MouseEvent) => {
- e.preventDefault();
- try {
- const response = await OpenHands.getVSCodeUrl();
- if (response.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(response.vscode_url, "_blank");
- } else {
- toast.error(
- `open-vscode-error-${new Date().getTime()}`,
- t(I18nKey.EXPLORER$VSCODE_SWITCHING_ERROR_MESSAGE, {
- error: response.error,
- }),
- );
- }
- } catch (exp_error) {
- toast.error(
- `open-vscode-error-${new Date().getTime()}`,
- t(I18nKey.EXPLORER$VSCODE_SWITCHING_ERROR_MESSAGE, {
- error: String(exp_error),
- }),
- );
- }
- };
-
- React.useEffect(() => {
- refreshWorkspace();
- }, [curAgentState]);
-
- return (
- {
- setIsDragging(true);
- }}
- onDragEnd={() => {
- setIsDragging(false);
- }}
- >
- {isDragging && (
-
setIsDragging(false)}
- onDrop={(event) => {
- event.preventDefault();
- const { files: droppedFiles } = event.dataTransfer;
- if (droppedFiles.length > 0) {
- uploadFileData(droppedFiles);
- }
- setIsDragging(false);
- }}
- 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"
- >
-
-
- {t(I18nKey.EXPLORER$LABEL_DROP_FILES)}
-
-
- )}
-
-
-
-
- {isOpen && (
-
- {t(I18nKey.EXPLORER$LABEL_WORKSPACE)}
-
- )}
-
-
-
- {!error && (
-
- )}
- {error && (
-
- )}
- {isOpen && (
-
- )}
-
-
{
- const { files: selectedFiles } = event.target;
- if (selectedFiles && selectedFiles.length > 0) {
- uploadFileData(selectedFiles);
- }
- }}
- />
-
-
- );
-}
-
-export default FileExplorer;
diff --git a/frontend/src/hooks/query/use-vscode-url.ts b/frontend/src/hooks/query/use-vscode-url.ts
new file mode 100644
index 000000000000..9c913e57cf4b
--- /dev/null
+++ b/frontend/src/hooks/query/use-vscode-url.ts
@@ -0,0 +1,43 @@
+import { useQuery } from "@tanstack/react-query";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { useDispatch } from "react-redux";
+import toast from "#/utils/toast";
+import { addAssistantMessage } from "#/state/chat-slice";
+import { I18nKey } from "#/i18n/declaration";
+import OpenHands from "#/api/open-hands";
+
+export const useVSCodeUrl = () => {
+ const { t } = useTranslation();
+ const dispatch = useDispatch();
+
+ const data = useQuery({
+ queryKey: ["vscode_url"],
+ queryFn: OpenHands.getVSCodeUrl,
+ enabled: false,
+ });
+
+ const { data: vscodeUrlObject, isFetching } = data;
+
+ React.useEffect(() => {
+ if (isFetching) return;
+
+ if (vscodeUrlObject?.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(vscodeUrlObject.vscode_url, "_blank");
+ } else if (vscodeUrlObject?.error) {
+ toast.error(
+ `open-vscode-error-${new Date().getTime()}`,
+ t(I18nKey.EXPLORER$VSCODE_SWITCHING_ERROR_MESSAGE, {
+ error: vscodeUrlObject.error,
+ }),
+ );
+ }
+ }, [vscodeUrlObject, isFetching]);
+
+ return data;
+};
diff --git a/frontend/src/routes/_oh.app._index/constants.ts b/frontend/src/routes/_oh.app._index/constants.ts
new file mode 100644
index 000000000000..b4b512172ae4
--- /dev/null
+++ b/frontend/src/routes/_oh.app._index/constants.ts
@@ -0,0 +1,11 @@
+export const ASSET_FILE_TYPES = [
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".bmp",
+ ".gif",
+ ".pdf",
+ ".mp4",
+ ".webm",
+ ".ogg",
+];
diff --git a/frontend/src/routes/_oh.app._index/file-explorer/buttons/open-vscode-button.tsx b/frontend/src/routes/_oh.app._index/file-explorer/buttons/open-vscode-button.tsx
new file mode 100644
index 000000000000..0c8dfbc3f40c
--- /dev/null
+++ b/frontend/src/routes/_oh.app._index/file-explorer/buttons/open-vscode-button.tsx
@@ -0,0 +1,30 @@
+import { cn } from "#/utils/utils";
+import VSCodeIcon from "#/assets/vscode-alt.svg?react";
+
+interface OpenVSCodeButtonProps {
+ isDisabled: boolean;
+ onClick: () => void;
+}
+
+export function OpenVSCodeButton({
+ isDisabled,
+ onClick,
+}: OpenVSCodeButtonProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/routes/_oh.app._index/file-explorer/buttons/refresh-icon-button.tsx b/frontend/src/routes/_oh.app._index/file-explorer/buttons/refresh-icon-button.tsx
new file mode 100644
index 000000000000..d21ab4160b35
--- /dev/null
+++ b/frontend/src/routes/_oh.app._index/file-explorer/buttons/refresh-icon-button.tsx
@@ -0,0 +1,22 @@
+import { IoIosRefresh } from "react-icons/io";
+import IconButton from "#/components/icon-button";
+
+interface RefreshIconButtonProps {
+ onClick: () => void;
+}
+
+export function RefreshIconButton({ onClick }: RefreshIconButtonProps) {
+ return (
+
+ }
+ testId="refresh"
+ ariaLabel="Refresh workspace"
+ onClick={onClick}
+ />
+ );
+}
diff --git a/frontend/src/routes/_oh.app._index/file-explorer/buttons/toggle-workspace-icon-button.tsx b/frontend/src/routes/_oh.app._index/file-explorer/buttons/toggle-workspace-icon-button.tsx
new file mode 100644
index 000000000000..5d42eeba0495
--- /dev/null
+++ b/frontend/src/routes/_oh.app._index/file-explorer/buttons/toggle-workspace-icon-button.tsx
@@ -0,0 +1,33 @@
+import { IoIosArrowForward, IoIosArrowBack } from "react-icons/io";
+import IconButton from "#/components/icon-button";
+
+interface ToggleWorkspaceIconButtonProps {
+ onClick: () => void;
+ isHidden: boolean;
+}
+
+export function ToggleWorkspaceIconButton({
+ onClick,
+ isHidden,
+}: ToggleWorkspaceIconButtonProps) {
+ return (
+
+ ) : (
+
+ )
+ }
+ testId="toggle"
+ ariaLabel={isHidden ? "Open workspace" : "Close workspace"}
+ onClick={onClick}
+ />
+ );
+}
diff --git a/frontend/src/routes/_oh.app._index/file-explorer/buttons/upload-icon-button.tsx b/frontend/src/routes/_oh.app._index/file-explorer/buttons/upload-icon-button.tsx
new file mode 100644
index 000000000000..b10ede48d806
--- /dev/null
+++ b/frontend/src/routes/_oh.app._index/file-explorer/buttons/upload-icon-button.tsx
@@ -0,0 +1,22 @@
+import { IoIosCloudUpload } from "react-icons/io";
+import IconButton from "#/components/icon-button";
+
+interface UploadIconButtonProps {
+ onClick: () => void;
+}
+
+export function UploadIconButton({ onClick }: UploadIconButtonProps) {
+ return (
+
+ }
+ testId="upload"
+ ariaLabel="Upload File"
+ onClick={onClick}
+ />
+ );
+}
diff --git a/frontend/src/routes/_oh.app._index/file-explorer/dropzone.tsx b/frontend/src/routes/_oh.app._index/file-explorer/dropzone.tsx
new file mode 100644
index 000000000000..3713fa2a78fb
--- /dev/null
+++ b/frontend/src/routes/_oh.app._index/file-explorer/dropzone.tsx
@@ -0,0 +1,27 @@
+import { useTranslation } from "react-i18next";
+import { IoFileTray } from "react-icons/io5";
+import { I18nKey } from "#/i18n/declaration";
+
+interface DropzoneProps {
+ onDragLeave: () => void;
+ onDrop: (event: React.DragEvent) => void;
+}
+
+export function Dropzone({ onDragLeave, onDrop }: DropzoneProps) {
+ const { t } = useTranslation();
+
+ return (
+ 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"
+ >
+
+
+ {t(I18nKey.EXPLORER$LABEL_DROP_FILES)}
+
+
+ );
+}
diff --git a/frontend/src/routes/_oh.app._index/file-explorer/file-explorer-actions.tsx b/frontend/src/routes/_oh.app._index/file-explorer/file-explorer-actions.tsx
new file mode 100644
index 000000000000..7fb9552ba2f3
--- /dev/null
+++ b/frontend/src/routes/_oh.app._index/file-explorer/file-explorer-actions.tsx
@@ -0,0 +1,36 @@
+import { cn } from "#/utils/utils";
+import { RefreshIconButton } from "./buttons/refresh-icon-button";
+import { ToggleWorkspaceIconButton } from "./buttons/toggle-workspace-icon-button";
+import { UploadIconButton } from "./buttons/upload-icon-button";
+
+interface ExplorerActionsProps {
+ onRefresh: () => void;
+ onUpload: () => void;
+ toggleHidden: () => void;
+ isHidden: boolean;
+}
+
+export function ExplorerActions({
+ toggleHidden,
+ onRefresh,
+ onUpload,
+ isHidden,
+}: ExplorerActionsProps) {
+ return (
+
+ {!isHidden && (
+ <>
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/routes/_oh.app._index/file-explorer/file-explorer-header.tsx b/frontend/src/routes/_oh.app._index/file-explorer/file-explorer-header.tsx
new file mode 100644
index 000000000000..d2329889f1fa
--- /dev/null
+++ b/frontend/src/routes/_oh.app._index/file-explorer/file-explorer-header.tsx
@@ -0,0 +1,42 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { cn } from "#/utils/utils";
+import { ExplorerActions } from "./file-explorer-actions";
+
+interface FileExplorerHeaderProps {
+ isOpen: boolean;
+ onToggle: () => void;
+ onRefreshWorkspace: () => void;
+ onUploadFile: () => void;
+}
+
+export function FileExplorerHeader({
+ isOpen,
+ onToggle,
+ onRefreshWorkspace,
+ onUploadFile,
+}: FileExplorerHeaderProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ {isOpen && (
+
+ {t(I18nKey.EXPLORER$LABEL_WORKSPACE)}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/routes/_oh.app._index/file-explorer/file-explorer.tsx b/frontend/src/routes/_oh.app._index/file-explorer/file-explorer.tsx
new file mode 100644
index 000000000000..a7979cacd381
--- /dev/null
+++ b/frontend/src/routes/_oh.app._index/file-explorer/file-explorer.tsx
@@ -0,0 +1,156 @@
+import React from "react";
+import { useSelector } from "react-redux";
+import { useTranslation } from "react-i18next";
+import AgentState from "#/types/agent-state";
+import ExplorerTree from "../../../components/file-explorer/explorer-tree";
+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 { OpenVSCodeButton } from "./buttons/open-vscode-button";
+import { Dropzone } from "./dropzone";
+import { FileExplorerHeader } from "./file-explorer-header";
+import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
+
+interface FileExplorerProps {
+ isOpen: boolean;
+ onToggle: () => void;
+}
+
+export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
+ const { t } = useTranslation();
+
+ const fileInputRef = React.useRef(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 { refetch: getVSCodeUrl } = useVSCodeUrl();
+
+ 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 (
+ curAgentState !== AgentState.LOADING &&
+ curAgentState !== AgentState.STOPPED
+ ) {
+ refetch();
+ }
+ };
+
+ const uploadFileData = (files: FileList) => {
+ uploadFiles(
+ { files: Array.from(files) },
+ { onSuccess: handleUploadSuccess, onError: handleUploadError },
+ );
+ refreshWorkspace();
+ };
+
+ const handleDropFiles = (event: React.DragEvent) => {
+ event.preventDefault();
+ const { files: droppedFiles } = event.dataTransfer;
+ if (droppedFiles.length > 0) {
+ uploadFileData(droppedFiles);
+ }
+ setIsDragging(false);
+ };
+
+ React.useEffect(() => {
+ refreshWorkspace();
+ }, [curAgentState]);
+
+ return (
+ {
+ setIsDragging(true);
+ }}
+ onDragEnd={() => {
+ setIsDragging(false);
+ }}
+ >
+ {isDragging && (
+
setIsDragging(false)}
+ onDrop={handleDropFiles}
+ />
+ )}
+
+
+
+ {!error && (
+
+ )}
+ {error && (
+
+ )}
+ {isOpen && (
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/routes/_oh.app._index/route.tsx b/frontend/src/routes/_oh.app._index/route.tsx
index 8ca4b5b345b5..99f583ecf74e 100644
--- a/frontend/src/routes/_oh.app._index/route.tsx
+++ b/frontend/src/routes/_oh.app._index/route.tsx
@@ -5,23 +5,12 @@ import { editor } from "monaco-editor";
import { EditorProps } from "@monaco-editor/react";
import { RootState } from "#/store";
import AgentState from "#/types/agent-state";
-import FileExplorer from "#/components/file-explorer/file-explorer";
+import { FileExplorer } from "#/routes/_oh.app._index/file-explorer/file-explorer";
import CodeEditorComponent from "./code-editor-component";
import { useFiles } from "#/context/files";
import { EditorActions } from "#/components/editor-actions";
import { useSaveFile } from "#/hooks/mutation/use-save-file";
-
-const ASSET_FILE_TYPES = [
- ".png",
- ".jpg",
- ".jpeg",
- ".bmp",
- ".gif",
- ".pdf",
- ".mp4",
- ".webm",
- ".ogg",
-];
+import { ASSET_FILE_TYPES } from "./constants";
export function ErrorBoundary() {
const error = useRouteError();