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 && (
+
}
+ >
+ {t(I18nKey.FEEDBACK$CANCEL_LABEL)}
+
- {name}
+
+ {name}
+ {isUnsaved && "*"}
+
);
}
@@ -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;