Просмотр исходного кода

Feat: unsaved file content (#3358)

Added file states, useEffect and destructor
tofarr 1 год назад
Родитель
Сommit
eab7ea3d37

+ 58 - 0
frontend/src/components/file-explorer/CodeEditor.test.tsx

@@ -0,0 +1,58 @@
+import React from "react";
+import { screen } from "@testing-library/react";
+import { renderWithProviders } from "test-utils";
+import CodeEditor from "./CodeEditor";
+
+describe("CodeEditor", () => {
+  afterEach(() => {
+    vi.resetAllMocks();
+  });
+
+  it("should render the code editor with save buttons when there is unsaved content", async () => {
+    renderWithProviders(<CodeEditor />, {
+      preloadedState: {
+        code: {
+          code: "Content for file1.txt",
+          path: "file1.txt", // appears in title
+          fileStates: [
+            {
+              path: "file1.txt",
+              unsavedContent: "Updated content for file1.txt",
+              savedContent: "Content for file1.txt",
+            },
+          ],
+          refreshID: 1234,
+        },
+      },
+    });
+
+    expect(await screen.findByText("file1.txt")).toBeInTheDocument();
+    expect(
+      await screen.findByText("CODE_EDITOR$SAVE_LABEL"),
+    ).toBeInTheDocument();
+  });
+
+  it("should render the code editor without save buttons when there is no unsaved content", async () => {
+    renderWithProviders(<CodeEditor />, {
+      preloadedState: {
+        code: {
+          code: "Content for file1.txt",
+          path: "file1.txt", // appears in title
+          fileStates: [
+            {
+              path: "file1.txt",
+              unsavedContent: "Content for file1.txt",
+              savedContent: "Content for file1.txt",
+            },
+          ],
+          refreshID: 1234,
+        },
+      },
+    });
+
+    expect(await screen.findByText("file1.txt")).toBeInTheDocument();
+    expect(
+      await screen.queryByText("CODE_EDITOR$SAVE_LABEL"),
+    ).not.toBeInTheDocument();
+  });
+});

+ 62 - 24
frontend/src/components/file-explorer/CodeEditor.tsx

@@ -3,12 +3,17 @@ import { useTranslation } from "react-i18next";
 import { useDispatch, useSelector } from "react-redux";
 import Editor, { Monaco } from "@monaco-editor/react";
 import { Tab, Tabs, Button } from "@nextui-org/react";
-import { VscCode, VscSave, VscCheck } from "react-icons/vsc";
+import { VscCode, VscSave, VscCheck, VscClose } from "react-icons/vsc";
 import type { editor } from "monaco-editor";
 import { I18nKey } from "#/i18n/declaration";
 import { RootState } from "#/store";
 import FileExplorer from "./FileExplorer";
-import { setCode } from "#/state/codeSlice";
+import {
+  setCode,
+  addOrUpdateFileState,
+  FileState,
+  setFileStates,
+} from "#/state/codeSlice";
 import toast from "#/utils/toast";
 import { saveFile } from "#/services/fileService";
 import AgentState from "#/types/AgentState";
@@ -16,8 +21,9 @@ import AgentState from "#/types/AgentState";
 function CodeEditor(): JSX.Element {
   const { t } = useTranslation();
   const dispatch = useDispatch();
-  const code = useSelector((state: RootState) => state.code.code);
+  const fileStates = useSelector((state: RootState) => state.code.fileStates);
   const activeFilepath = useSelector((state: RootState) => state.code.path);
+  const fileState = fileStates.find((f) => f.path === activeFilepath);
   const agentState = useSelector(
     (state: RootState) => state.agent.curAgentState,
   );
@@ -25,8 +31,8 @@ function CodeEditor(): JSX.Element {
     "idle" | "saving" | "saved" | "error"
   >("idle");
   const [showSaveNotification, setShowSaveNotification] = useState(false);
-  const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
-  const [lastSavedContent, setLastSavedContent] = useState(code);
+  const unsavedContent = fileState?.unsavedContent;
+  const hasUnsavedChanges = fileState?.savedContent !== unsavedContent;
 
   const selectedFileName = useMemo(() => {
     const paths = activeFilepath.split("/");
@@ -44,22 +50,36 @@ function CodeEditor(): JSX.Element {
 
   useEffect(() => {
     setSaveStatus("idle");
-    setHasUnsavedChanges(false);
-    setLastSavedContent(code);
+    // Clear out any file states where the file is not being viewed and does not have any changes
+    const newFileStates = fileStates.filter(
+      (f) => f.path === activeFilepath || f.savedContent !== f.unsavedContent,
+    );
+    if (fileStates.length !== newFileStates.length) {
+      dispatch(setFileStates(newFileStates));
+    }
   }, [activeFilepath]);
 
   useEffect(() => {
-    setHasUnsavedChanges(code !== lastSavedContent);
-  }, [code, lastSavedContent]);
+    if (!showSaveNotification) {
+      return undefined;
+    }
+    const timeout = setTimeout(() => setShowSaveNotification(false), 2000);
+    return () => clearTimeout(timeout);
+  }, [showSaveNotification]);
 
   const handleEditorChange = useCallback(
     (value: string | undefined): void => {
       if (value !== undefined && isEditingAllowed) {
         dispatch(setCode(value));
-        setHasUnsavedChanges(true);
+        const newFileState = {
+          path: activeFilepath,
+          savedContent: fileState?.savedContent,
+          unsavedContent: value,
+        };
+        dispatch(addOrUpdateFileState(newFileState));
       }
     },
-    [dispatch, isEditingAllowed],
+    [activeFilepath, dispatch, isEditingAllowed],
   );
 
   const handleEditorDidMount = useCallback(
@@ -84,12 +104,18 @@ function CodeEditor(): JSX.Element {
     setSaveStatus("saving");
 
     try {
-      await saveFile(activeFilepath, code);
+      const newContent = fileState?.unsavedContent;
+      if (newContent) {
+        await saveFile(activeFilepath, newContent);
+      }
       setSaveStatus("saved");
       setShowSaveNotification(true);
-      setLastSavedContent(code);
-      setHasUnsavedChanges(false);
-      setTimeout(() => setShowSaveNotification(false), 2000);
+      const newFileState = {
+        path: activeFilepath,
+        savedContent: newContent,
+        unsavedContent: newContent,
+      };
+      dispatch(addOrUpdateFileState(newFileState));
       toast.success(
         "file-save-success",
         t(I18nKey.CODE_EDITOR$FILE_SAVED_SUCCESSFULLY),
@@ -105,14 +131,18 @@ function CodeEditor(): JSX.Element {
         toast.error("file-save-error", t(I18nKey.CODE_EDITOR$FILE_SAVE_ERROR));
       }
     }
-  }, [
-    saveStatus,
-    activeFilepath,
-    code,
-    isEditingAllowed,
-    t,
-    hasUnsavedChanges,
-  ]);
+  }, [saveStatus, activeFilepath, unsavedContent, isEditingAllowed, t]);
+
+  const handleCancel = useCallback(() => {
+    const { path, savedContent } = fileState as FileState;
+    dispatch(
+      addOrUpdateFileState({
+        path,
+        savedContent,
+        unsavedContent: savedContent,
+      }),
+    );
+  }, [activeFilepath, unsavedContent]);
 
   const getSaveButtonColor = () => {
     switch (saveStatus) {
@@ -151,6 +181,14 @@ function CodeEditor(): JSX.Element {
           </Tabs>
           {selectedFileName && hasUnsavedChanges && (
             <div className="flex items-center mr-2">
+              <Button
+                onClick={handleCancel}
+                className="text-white transition-colors duration-300 mr-2"
+                size="sm"
+                startContent={<VscClose />}
+              >
+                {t(I18nKey.FEEDBACK$CANCEL_LABEL)}
+              </Button>
               <Button
                 onClick={handleSave}
                 className={`${getSaveButtonColor()} text-white transition-colors duration-300 mr-2`}
@@ -176,7 +214,7 @@ function CodeEditor(): JSX.Element {
               height="100%"
               path={selectedFileName.toLowerCase()}
               defaultValue=""
-              value={code}
+              value={unsavedContent}
               onMount={handleEditorDidMount}
               onChange={handleEditorChange}
               options={{ readOnly: !isEditingAllowed }}

+ 22 - 5
frontend/src/components/file-explorer/TreeNode.tsx

@@ -5,16 +5,21 @@ import { RootState } from "#/store";
 import FolderIcon from "../FolderIcon";
 import FileIcon from "../FileIcons";
 import { listFiles, selectFile } from "#/services/fileService";
-import { setCode, setActiveFilepath } from "#/state/codeSlice";
+import {
+  setCode,
+  setActiveFilepath,
+  addOrUpdateFileState,
+} from "#/state/codeSlice";
 
 interface TitleProps {
   name: string;
   type: "folder" | "file";
   isOpen: boolean;
+  isUnsaved: boolean;
   onClick: () => void;
 }
 
-function Title({ name, type, isOpen, onClick }: TitleProps) {
+function Title({ name, type, isOpen, isUnsaved, onClick }: TitleProps) {
   return (
     <div
       onClick={onClick}
@@ -24,7 +29,10 @@ function Title({ name, type, isOpen, onClick }: TitleProps) {
         {type === "folder" && <FolderIcon isOpen={isOpen} />}
         {type === "file" && <FileIcon filename={name} />}
       </div>
-      <div className="flex-grow">{name}</div>
+      <div className="flex-grow">
+        {name}
+        {isUnsaved && "*"}
+      </div>
     </div>
   );
 }
@@ -39,6 +47,9 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
   const [children, setChildren] = React.useState<string[] | null>(null);
   const refreshID = useSelector((state: RootState) => state.code.refreshID);
   const activeFilepath = useSelector((state: RootState) => state.code.path);
+  const fileStates = useSelector((state: RootState) => state.code.fileStates);
+  const fileState = fileStates.find((f) => f.path === path);
+  const isUnsaved = fileState?.savedContent !== fileState?.unsavedContent;
 
   const dispatch = useDispatch();
 
@@ -67,8 +78,13 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
     if (isDirectory) {
       setIsOpen((prev) => !prev);
     } else {
-      const newCode = await selectFile(path);
-      dispatch(setCode(newCode));
+      let newFileState = fileStates.find((f) => f.path === path);
+      if (!newFileState) {
+        const code = await selectFile(path);
+        newFileState = { path, savedContent: code, unsavedContent: code };
+      }
+      dispatch(addOrUpdateFileState(newFileState));
+      dispatch(setCode(newFileState.unsavedContent));
       dispatch(setActiveFilepath(path));
     }
   };
@@ -84,6 +100,7 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
         name={filename}
         type={isDirectory ? "folder" : "file"}
         isOpen={isOpen}
+        isUnsaved={isUnsaved}
         onClick={handleClick}
       />
 

+ 32 - 1
frontend/src/state/codeSlice.ts

@@ -1,9 +1,16 @@
 import { createSlice } from "@reduxjs/toolkit";
 
+export interface FileState {
+  path: string;
+  savedContent: string;
+  unsavedContent: string;
+}
+
 export const initialState = {
   code: "",
   path: "",
   refreshID: 0,
+  fileStates: [] as FileState[],
 };
 
 export const codeSlice = createSlice({
@@ -19,9 +26,33 @@ export const codeSlice = createSlice({
     setRefreshID: (state, action) => {
       state.refreshID = action.payload;
     },
+    setFileStates: (state, action) => {
+      state.fileStates = action.payload;
+    },
+    addOrUpdateFileState: (state, action) => {
+      const { path, unsavedContent, savedContent } = action.payload;
+      const newFileStates = state.fileStates.filter(
+        (fileState) => fileState.path !== path,
+      );
+      newFileStates.push({ path, savedContent, unsavedContent });
+      state.fileStates = newFileStates;
+    },
+    removeFileState: (state, action) => {
+      const path = action.payload;
+      state.fileStates = state.fileStates.filter(
+        (fileState) => fileState.path !== path,
+      );
+    },
   },
 });
 
-export const { setCode, setActiveFilepath, setRefreshID } = codeSlice.actions;
+export const {
+  setCode,
+  setActiveFilepath,
+  setRefreshID,
+  addOrUpdateFileState,
+  removeFileState,
+  setFileStates,
+} = codeSlice.actions;
 
 export default codeSlice.reducer;