Feat: unsaved file content (#3358)

Added file states, useEffect and destructor
This commit is contained in:
tofarr 2024-08-15 18:21:49 -06:00 committed by GitHub
parent c67df47c10
commit eab7ea3d37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 174 additions and 30 deletions

View File

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

View File

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

View File

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

View File

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