diff --git a/web/src/components/editor/EditorPanel.tsx b/web/src/components/editor/EditorPanel.tsx index a3f108e..ce8fcb5 100644 --- a/web/src/components/editor/EditorPanel.tsx +++ b/web/src/components/editor/EditorPanel.tsx @@ -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(undefined); // Sync editor buffer with fetched content when the file changes or loads. useEffect(() => { @@ -42,48 +44,68 @@ 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. + const clearPendingSave = () => { + 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(() => { 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]); + }, [path, save]); - // Wire Cmd/Ctrl+S to save the current buffer. - const handleMount: OnMount = (editor, monaco) => { - editor.addCommand( - monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_S, - () => { - 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), - }, - ); - } - }, - ); - }; + // 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. @@ -115,6 +137,19 @@ export function EditorPanel({ workspaceId, path, language }: EditorPanelProps) { {write.isError && ( {write.error.message} )} + {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 },