diff --git a/frontend/src/components/file-explorer/CodeEditor.test.tsx b/frontend/src/components/file-explorer/CodeEditor.test.tsx new file mode 100644 index 0000000000..80b388ed0a --- /dev/null +++ b/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(, { + 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(, { + 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(); + }); +}); diff --git a/frontend/src/components/file-explorer/CodeEditor.tsx b/frontend/src/components/file-explorer/CodeEditor.tsx index b7096d48c0..1294f616c8 100644 --- a/frontend/src/components/file-explorer/CodeEditor.tsx +++ b/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 { {selectedFileName && hasUnsavedChanges && (
+
); } @@ -39,6 +47,9 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) { const [children, setChildren] = React.useState(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} /> diff --git a/frontend/src/state/codeSlice.ts b/frontend/src/state/codeSlice.ts index 3f5dd6571a..72e002d542 100644 --- a/frontend/src/state/codeSlice.ts +++ b/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;