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
+55 -21
View File
@@ -1,5 +1,6 @@
import Editor, { type OnMount, type OnValidate } from "@monaco-editor/react";
import { useEffect, useRef, useState } from "react";
import Editor, { type OnValidate } from "@monaco-editor/react";
import { Loader2, Save } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { inferLanguage, useFileRead, useFileWrite } from "@/lib/api/files";
@@ -17,6 +18,7 @@ export function EditorPanel({ workspaceId, path, language }: EditorPanelProps) {
const [savedContent, setSavedContent] = useState("");
const dirty = buffer !== savedContent;
const saveInProgress = write.isPending;
const saveTimeoutRef = useRef<number | undefined>(undefined);
// Sync editor buffer with fetched content when the file changes or loads.
useEffect(() => {
@@ -42,25 +44,14 @@ export function EditorPanel({ workspaceId, path, language }: EditorPanelProps) {
workspaceIdRef.current = workspaceId;
}, [buffer, savedContent, path, workspaceId]);
// Suppress the browser's Save Page dialog on Cmd/Ctrl+S when a file is open.
// Monaco's own keybinding still handles saving.
useEffect(() => {
if (!path) return;
const handler = (e: KeyboardEvent) => {
if ((e.key === "s" || e.key === "S") && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
e.stopPropagation();
const clearPendingSave = () => {
if (saveTimeoutRef.current !== undefined) {
window.clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = undefined;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [path]);
// Wire Cmd/Ctrl+S to save the current buffer.
const handleMount: OnMount = (editor, monaco) => {
editor.addCommand(
monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_S,
() => {
const save = useCallback(() => {
const currentBuffer = bufferRef.current;
const currentSaved = savedContentRef.current;
const currentPath = pathRef.current;
@@ -81,10 +72,41 @@ export function EditorPanel({ workspaceId, path, language }: EditorPanelProps) {
},
);
}
},
);
}, [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(() => {
if (!path) return;
const handler = (e: KeyboardEvent) => {
if ((e.key === "s" || e.key === "S") && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
e.stopPropagation();
save();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [path, save]);
// Auto-save 2s after the last edit when the buffer is dirty.
useEffect(() => {
if (!dirty) {
clearPendingSave();
return;
}
clearPendingSave();
saveTimeoutRef.current = window.setTimeout(() => {
save();
}, 2000);
return () => clearPendingSave();
}, [buffer, dirty, save]);
// Resetting dirty state on file switch is acceptable for this task because
// each file gets its own EditorPanel mount via the `key={path}` prop.
const [, setMarkers] = useState<Parameters<OnValidate>[0]>([]);
@@ -115,6 +137,19 @@ export function EditorPanel({ workspaceId, path, language }: EditorPanelProps) {
{write.isError && (
<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>
</header>
{readError && (
@@ -135,7 +170,6 @@ export function EditorPanel({ workspaceId, path, language }: EditorPanelProps) {
value={buffer}
onChange={(value) => setBuffer(value ?? "")}
onValidate={setMarkers}
onMount={handleMount}
options={{
automaticLayout: true,
minimap: { enabled: false },