refactor(frontend): App index route (mainly file explorer) (#5287)

This commit is contained in:
sp.wack
2024-11-27 09:46:30 +04:00
committed by GitHub
parent 9a96e9f1e4
commit 5d366129d1
13 changed files with 425 additions and 321 deletions

View File

@@ -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");

View File

@@ -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 (
<div
className={twMerge(
"transform flex h-[24px] items-center gap-1",
isHidden ? "right-3" : "right-2",
)}
>
{!isHidden && (
<>
<IconButton
icon={
<IoIosRefresh
size={16}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
}
testId="refresh"
ariaLabel="Refresh workspace"
onClick={onRefresh}
/>
<IconButton
icon={
<IoIosCloudUpload
size={16}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
}
testId="upload"
ariaLabel="Upload File"
onClick={onUpload}
/>
</>
)}
<IconButton
icon={
isHidden ? (
<IoIosArrowForward
size={20}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
) : (
<IoIosArrowBack
size={20}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
)
}
testId="toggle"
ariaLabel={isHidden ? "Open workspace" : "Close workspace"}
onClick={toggleHidden}
/>
</div>
);
}
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<HTMLInputElement | null>(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 (
<div
data-testid="file-explorer"
className="relative h-full"
onDragEnter={() => {
setIsDragging(true);
}}
onDragEnd={() => {
setIsDragging(false);
}}
>
{isDragging && (
<div
data-testid="dropzone"
onDragLeave={() => 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"
>
<IoFileTray size={32} />
<p className="font-bold text-xl">
{t(I18nKey.EXPLORER$LABEL_DROP_FILES)}
</p>
</div>
)}
<div
className={twMerge(
"bg-neutral-800 h-full border-r-1 border-r-neutral-600 flex flex-col",
!isOpen ? "w-12" : "w-60",
)}
>
<div className="flex flex-col relative h-full px-3 py-2 overflow-hidden">
<div className="sticky top-0 bg-neutral-800">
<div
className={twMerge(
"flex items-center",
!isOpen ? "justify-center" : "justify-between",
)}
>
{isOpen && (
<div className="text-neutral-300 font-bold text-sm">
{t(I18nKey.EXPLORER$LABEL_WORKSPACE)}
</div>
)}
<ExplorerActions
isHidden={!isOpen}
toggleHidden={onToggle}
onRefresh={refreshWorkspace}
onUpload={selectFileInput}
/>
</div>
</div>
{!error && (
<div className="overflow-auto flex-grow min-h-0">
<div style={{ display: !isOpen ? "none" : "block" }}>
<ExplorerTree files={paths || []} />
</div>
</div>
)}
{error && (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-neutral-300 text-sm">{error.message}</p>
</div>
)}
{isOpen && (
<button
type="button"
onClick={handleVSCodeClick}
disabled={
curAgentState === AgentState.INIT ||
curAgentState === AgentState.LOADING
}
className={twMerge(
"mt-auto mb-2 w-full h-10 text-white rounded flex items-center justify-center gap-2 transition-colors",
curAgentState === AgentState.INIT ||
curAgentState === AgentState.LOADING
? "bg-neutral-600 cursor-not-allowed"
: "bg-[#4465DB] hover:bg-[#3451C7]",
)}
aria-label="Open in VS Code"
>
<VSCodeIcon width={20} height={20} />
Open in VS Code
</button>
)}
</div>
<input
data-testid="file-input"
type="file"
multiple
ref={fileInputRef}
style={{ display: "none" }}
onChange={(event) => {
const { files: selectedFiles } = event.target;
if (selectedFiles && selectedFiles.length > 0) {
uploadFileData(selectedFiles);
}
}}
/>
</div>
</div>
);
}
export default FileExplorer;

View File

@@ -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;
};

View File

@@ -0,0 +1,11 @@
export const ASSET_FILE_TYPES = [
".png",
".jpg",
".jpeg",
".bmp",
".gif",
".pdf",
".mp4",
".webm",
".ogg",
];

View File

@@ -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 (
<button
type="button"
onClick={onClick}
disabled={isDisabled}
className={cn(
"mt-auto mb-2 w-full h-10 text-white rounded flex items-center justify-center gap-2 transition-colors",
isDisabled
? "bg-neutral-600 cursor-not-allowed"
: "bg-[#4465DB] hover:bg-[#3451C7]",
)}
aria-label="Open in VS Code"
>
<VSCodeIcon width={20} height={20} />
Open in VS Code
</button>
);
}

View File

@@ -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 (
<IconButton
icon={
<IoIosRefresh
size={16}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
}
testId="refresh"
ariaLabel="Refresh workspace"
onClick={onClick}
/>
);
}

View File

@@ -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 (
<IconButton
icon={
isHidden ? (
<IoIosArrowForward
size={20}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
) : (
<IoIosArrowBack
size={20}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
)
}
testId="toggle"
ariaLabel={isHidden ? "Open workspace" : "Close workspace"}
onClick={onClick}
/>
);
}

View File

@@ -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 (
<IconButton
icon={
<IoIosCloudUpload
size={16}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
}
testId="upload"
ariaLabel="Upload File"
onClick={onClick}
/>
);
}

View File

@@ -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<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

@@ -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 (
<div
className={cn(
"flex h-[24px] items-center gap-1",
isHidden ? "right-3" : "right-2",
)}
>
{!isHidden && (
<>
<RefreshIconButton onClick={onRefresh} />
<UploadIconButton onClick={onUpload} />
</>
)}
<ToggleWorkspaceIconButton isHidden={isHidden} onClick={toggleHidden} />
</div>
);
}

View File

@@ -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 (
<div
className={cn(
"sticky top-0 bg-neutral-800",
"flex items-center",
!isOpen ? "justify-center" : "justify-between",
)}
>
{isOpen && (
<div className="text-neutral-300 font-bold text-sm">
{t(I18nKey.EXPLORER$LABEL_WORKSPACE)}
</div>
)}
<ExplorerActions
isHidden={!isOpen}
toggleHidden={onToggle}
onRefresh={onRefreshWorkspace}
onUpload={onUploadFile}
/>
</div>
);
}

View File

@@ -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<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 { 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<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
className={cn(
"bg-neutral-800 h-full border-r-1 border-r-neutral-600 flex flex-col",
!isOpen ? "w-12" : "w-60",
)}
>
<div className="flex flex-col relative h-full px-3 py-2 overflow-hidden">
<FileExplorerHeader
isOpen={isOpen}
onToggle={onToggle}
onRefreshWorkspace={refreshWorkspace}
onUploadFile={selectFileInput}
/>
{!error && (
<div className="overflow-auto flex-grow min-h-0">
<div style={{ display: !isOpen ? "none" : "block" }}>
<ExplorerTree files={paths || []} />
</div>
</div>
)}
{error && (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-neutral-300 text-sm">{error.message}</p>
</div>
)}
{isOpen && (
<OpenVSCodeButton
onClick={getVSCodeUrl}
isDisabled={
curAgentState === AgentState.INIT ||
curAgentState === AgentState.LOADING
}
/>
)}
</div>
</div>
</div>
);
}

View File

@@ -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();