fix(web): move save logic from monaco to react with manual and auto-save

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-03 18:25:47 +08:00
co-authored by Claude
parent 7a6e7e7ebb
commit 3c6e0c3121
+68 -34
View File
@@ -1,5 +1,6 @@
import Editor, { type OnMount, type OnValidate } from "@monaco-editor/react"; import Editor, { type OnValidate } from "@monaco-editor/react";
import { useEffect, useRef, useState } from "react"; import { Loader2, Save } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { inferLanguage, useFileRead, useFileWrite } from "@/lib/api/files"; import { inferLanguage, useFileRead, useFileWrite } from "@/lib/api/files";
@@ -17,6 +18,7 @@ export function EditorPanel({ workspaceId, path, language }: EditorPanelProps) {
const [savedContent, setSavedContent] = useState(""); const [savedContent, setSavedContent] = useState("");
const dirty = buffer !== savedContent; const dirty = buffer !== savedContent;
const saveInProgress = write.isPending; const saveInProgress = write.isPending;
const saveTimeoutRef = useRef<number | undefined>(undefined);
// Sync editor buffer with fetched content when the file changes or loads. // Sync editor buffer with fetched content when the file changes or loads.
useEffect(() => { useEffect(() => {
@@ -42,48 +44,68 @@ export function EditorPanel({ workspaceId, path, language }: EditorPanelProps) {
workspaceIdRef.current = workspaceId; workspaceIdRef.current = workspaceId;
}, [buffer, savedContent, path, workspaceId]); }, [buffer, savedContent, path, workspaceId]);
// Suppress the browser's Save Page dialog on Cmd/Ctrl+S when a file is open. const clearPendingSave = () => {
// Monaco's own keybinding still handles saving. if (saveTimeoutRef.current !== undefined) {
window.clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = undefined;
}
};
const save = useCallback(() => {
const currentBuffer = bufferRef.current;
const currentSaved = savedContentRef.current;
const currentPath = pathRef.current;
const currentWorkspaceId = workspaceIdRef.current;
if (
currentWorkspaceId &&
currentPath &&
currentBuffer !== currentSaved
) {
write.mutate(
{
workspaceId: currentWorkspaceId,
path: currentPath,
content: currentBuffer,
},
{
onSuccess: () => setSavedContent(currentBuffer),
},
);
}
}, [write]);
const handleManualSave = () => {
clearPendingSave();
save();
};
// Suppress the browser's Save Page dialog on Cmd/Ctrl+S when a file is open
// and trigger save from React-owned code.
useEffect(() => { useEffect(() => {
if (!path) return; if (!path) return;
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if ((e.key === "s" || e.key === "S") && (e.ctrlKey || e.metaKey)) { if ((e.key === "s" || e.key === "S") && (e.ctrlKey || e.metaKey)) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
save();
} }
}; };
window.addEventListener("keydown", handler); window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);
}, [path]); }, [path, save]);
// Wire Cmd/Ctrl+S to save the current buffer. // Auto-save 2s after the last edit when the buffer is dirty.
const handleMount: OnMount = (editor, monaco) => { useEffect(() => {
editor.addCommand( if (!dirty) {
monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_S, clearPendingSave();
() => { return;
const currentBuffer = bufferRef.current; }
const currentSaved = savedContentRef.current; clearPendingSave();
const currentPath = pathRef.current; saveTimeoutRef.current = window.setTimeout(() => {
const currentWorkspaceId = workspaceIdRef.current; save();
if ( }, 2000);
currentWorkspaceId && return () => clearPendingSave();
currentPath && }, [buffer, dirty, save]);
currentBuffer !== currentSaved
) {
write.mutate(
{
workspaceId: currentWorkspaceId,
path: currentPath,
content: currentBuffer,
},
{
onSuccess: () => setSavedContent(currentBuffer),
},
);
}
},
);
};
// Resetting dirty state on file switch is acceptable for this task because // Resetting dirty state on file switch is acceptable for this task because
// each file gets its own EditorPanel mount via the `key={path}` prop. // each file gets its own EditorPanel mount via the `key={path}` prop.
@@ -115,6 +137,19 @@ export function EditorPanel({ workspaceId, path, language }: EditorPanelProps) {
{write.isError && ( {write.isError && (
<span className="text-destructive">{write.error.message}</span> <span className="text-destructive">{write.error.message}</span>
)} )}
<button
type="button"
onClick={handleManualSave}
disabled={!dirty || saveInProgress}
className="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
aria-label="Save"
>
{saveInProgress ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
</button>
</div> </div>
</header> </header>
{readError && ( {readError && (
@@ -135,7 +170,6 @@ export function EditorPanel({ workspaceId, path, language }: EditorPanelProps) {
value={buffer} value={buffer}
onChange={(value) => setBuffer(value ?? "")} onChange={(value) => setBuffer(value ?? "")}
onValidate={setMarkers} onValidate={setMarkers}
onMount={handleMount}
options={{ options={{
automaticLayout: true, automaticLayout: true,
minimap: { enabled: false }, minimap: { enabled: false },