FileExplorer.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import React from "react";
  2. import {
  3. IoIosArrowBack,
  4. IoIosArrowForward,
  5. IoIosRefresh,
  6. IoIosCloudUpload,
  7. } from "react-icons/io";
  8. import { useDispatch, useSelector } from "react-redux";
  9. import { IoFileTray } from "react-icons/io5";
  10. import { useTranslation } from "react-i18next";
  11. import { twMerge } from "tailwind-merge";
  12. import AgentState from "#/types/AgentState";
  13. import { setRefreshID } from "#/state/codeSlice";
  14. import { listFiles, uploadFiles } from "#/services/fileService";
  15. import IconButton from "../IconButton";
  16. import ExplorerTree from "./ExplorerTree";
  17. import toast from "#/utils/toast";
  18. import { RootState } from "#/store";
  19. import { I18nKey } from "#/i18n/declaration";
  20. interface ExplorerActionsProps {
  21. onRefresh: () => void;
  22. onUpload: () => void;
  23. toggleHidden: () => void;
  24. isHidden: boolean;
  25. }
  26. function ExplorerActions({
  27. toggleHidden,
  28. onRefresh,
  29. onUpload,
  30. isHidden,
  31. }: ExplorerActionsProps) {
  32. return (
  33. <div
  34. className={twMerge(
  35. "transform flex h-[24px] items-center gap-1",
  36. isHidden ? "right-3" : "right-2",
  37. )}
  38. >
  39. {!isHidden && (
  40. <>
  41. <IconButton
  42. icon={
  43. <IoIosRefresh
  44. size={16}
  45. className="text-neutral-400 hover:text-neutral-100 transition"
  46. />
  47. }
  48. testId="refresh"
  49. ariaLabel="Refresh workspace"
  50. onClick={onRefresh}
  51. />
  52. <IconButton
  53. icon={
  54. <IoIosCloudUpload
  55. size={16}
  56. className="text-neutral-400 hover:text-neutral-100 transition"
  57. />
  58. }
  59. testId="upload"
  60. ariaLabel="Upload File"
  61. onClick={onUpload}
  62. />
  63. </>
  64. )}
  65. <IconButton
  66. icon={
  67. isHidden ? (
  68. <IoIosArrowForward
  69. size={20}
  70. className="text-neutral-400 hover:text-neutral-100 transition"
  71. />
  72. ) : (
  73. <IoIosArrowBack
  74. size={20}
  75. className="text-neutral-400 hover:text-neutral-100 transition"
  76. />
  77. )
  78. }
  79. testId="toggle"
  80. ariaLabel={isHidden ? "Open workspace" : "Close workspace"}
  81. onClick={toggleHidden}
  82. />
  83. </div>
  84. );
  85. }
  86. function FileExplorer() {
  87. const [isHidden, setIsHidden] = React.useState(false);
  88. const [isDragging, setIsDragging] = React.useState(false);
  89. const [files, setFiles] = React.useState<string[]>([]);
  90. const { curAgentState } = useSelector((state: RootState) => state.agent);
  91. const fileInputRef = React.useRef<HTMLInputElement | null>(null);
  92. const dispatch = useDispatch();
  93. const { t } = useTranslation();
  94. const selectFileInput = () => {
  95. fileInputRef.current?.click(); // Trigger the file browser
  96. };
  97. const refreshWorkspace = async () => {
  98. if (
  99. curAgentState === AgentState.LOADING ||
  100. curAgentState === AgentState.STOPPED
  101. ) {
  102. return;
  103. }
  104. dispatch(setRefreshID(Math.random()));
  105. try {
  106. const fileList = await listFiles("/");
  107. setFiles(fileList);
  108. if (fileList.length === 0) {
  109. toast.info(t(I18nKey.EXPLORER$EMPTY_WORKSPACE_MESSAGE));
  110. }
  111. } catch (error) {
  112. toast.error("refresh-error", t(I18nKey.EXPLORER$REFRESH_ERROR_MESSAGE));
  113. }
  114. };
  115. const uploadFileData = async (toAdd: FileList) => {
  116. try {
  117. const result = await uploadFiles(toAdd);
  118. if (result.error) {
  119. // Handle error response
  120. toast.error(
  121. `upload-error-${new Date().getTime()}`,
  122. result.error || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
  123. );
  124. return;
  125. }
  126. const uploadedCount = result.uploadedFiles.length;
  127. const skippedCount = result.skippedFiles.length;
  128. if (uploadedCount > 0) {
  129. toast.success(
  130. `upload-success-${new Date().getTime()}`,
  131. t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, {
  132. count: uploadedCount,
  133. }),
  134. );
  135. }
  136. if (skippedCount > 0) {
  137. const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, {
  138. count: skippedCount,
  139. });
  140. toast.info(message);
  141. }
  142. if (uploadedCount === 0 && skippedCount === 0) {
  143. toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE));
  144. }
  145. await refreshWorkspace();
  146. } catch (error) {
  147. // Handle unexpected errors (network issues, etc.)
  148. toast.error(
  149. `upload-error-${new Date().getTime()}`,
  150. t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
  151. );
  152. }
  153. };
  154. React.useEffect(() => {
  155. (async () => {
  156. await refreshWorkspace();
  157. })();
  158. }, [curAgentState]);
  159. React.useEffect(() => {
  160. const enableDragging = () => {
  161. setIsDragging(true);
  162. };
  163. const disableDragging = () => {
  164. setIsDragging(false);
  165. };
  166. document.addEventListener("dragenter", enableDragging);
  167. document.addEventListener("drop", disableDragging);
  168. return () => {
  169. document.removeEventListener("dragenter", enableDragging);
  170. document.removeEventListener("drop", disableDragging);
  171. };
  172. }, []);
  173. if (!files.length) {
  174. return null;
  175. }
  176. return (
  177. <div className="relative h-full">
  178. {isDragging && (
  179. <div
  180. data-testid="dropzone"
  181. onDrop={(event) => {
  182. event.preventDefault();
  183. const { files: droppedFiles } = event.dataTransfer;
  184. if (droppedFiles.length > 0) {
  185. uploadFileData(droppedFiles);
  186. }
  187. }}
  188. onDragOver={(event) => event.preventDefault()}
  189. className="z-10 absolute flex flex-col justify-center items-center bg-black top-0 bottom-0 left-0 right-0 opacity-65"
  190. >
  191. <IoFileTray size={32} />
  192. <p className="font-bold text-xl">
  193. {t(I18nKey.EXPLORER$LABEL_DROP_FILES)}
  194. </p>
  195. </div>
  196. )}
  197. <div
  198. className={twMerge(
  199. "bg-neutral-800 h-full border-r-1 border-r-neutral-600 flex flex-col transition-all ease-soft-spring",
  200. isHidden ? "min-w-[48px]" : "min-w-[228px]",
  201. )}
  202. >
  203. <div className="flex flex-col relative h-full">
  204. <div className="sticky top-0 bg-neutral-800 z-10">
  205. <div
  206. className={twMerge(
  207. "flex items-center mt-2 mb-1 p-2",
  208. isHidden ? "justify-center" : "justify-between",
  209. )}
  210. >
  211. {!isHidden && (
  212. <div className="ml-1 text-neutral-300 font-bold text-sm">
  213. <div className="ml-1 text-neutral-300 font-bold text-sm">
  214. {t(I18nKey.EXPLORER$LABEL_WORKSPACE)}
  215. </div>
  216. </div>
  217. )}
  218. <ExplorerActions
  219. isHidden={isHidden}
  220. toggleHidden={() => setIsHidden((prev) => !prev)}
  221. onRefresh={refreshWorkspace}
  222. onUpload={selectFileInput}
  223. />
  224. </div>
  225. </div>
  226. <div className="overflow-auto flex-grow">
  227. <div style={{ display: isHidden ? "none" : "block" }}>
  228. <ExplorerTree files={files} defaultOpen />
  229. </div>
  230. </div>
  231. </div>
  232. <input
  233. data-testid="file-input"
  234. type="file"
  235. multiple
  236. ref={fileInputRef}
  237. style={{ display: "none" }}
  238. onChange={(event) => {
  239. const { files: selectedFiles } = event.target;
  240. if (selectedFiles && selectedFiles.length > 0) {
  241. uploadFileData(selectedFiles);
  242. }
  243. }}
  244. />
  245. </div>
  246. </div>
  247. );
  248. }
  249. export default FileExplorer;