From cdfac7b3792d3813594daa36470465bd8c841ea9 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Wed, 24 Dec 2025 00:23:05 +0000
Subject: Add overwrite mechanism for conflicting writes of files

---
 .../frontend/src/components/files/BodyRenderer.tsx | 200 ++++++++++++++++++---
 1 file changed, 171 insertions(+), 29 deletions(-)

(limited to 'makima/frontend/src/components/files/BodyRenderer.tsx')

diff --git a/makima/frontend/src/components/files/BodyRenderer.tsx b/makima/frontend/src/components/files/BodyRenderer.tsx
index 06b2b75..867fc4c 100644
--- a/makima/frontend/src/components/files/BodyRenderer.tsx
+++ b/makima/frontend/src/components/files/BodyRenderer.tsx
@@ -7,9 +7,12 @@ interface BodyRendererProps {
   isEditing?: boolean;
   onUpdate?: (index: number, element: BodyElement) => void;
   onReorder?: (fromIndex: number, toIndex: number) => void;
+  onEditingChange?: (isEditing: boolean) => void;
+  hasPendingRemoteUpdate?: boolean;
+  onOverwrite?: () => void;
 }
 
-export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder }: BodyRendererProps) {
+export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder, onEditingChange, hasPendingRemoteUpdate, onOverwrite }: BodyRendererProps) {
   const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
   const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
 
@@ -99,6 +102,9 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder
             <BodyElementRenderer
               element={element}
               onUpdate={isEditing && onUpdate ? (el) => onUpdate(index, el) : undefined}
+              onEditingChange={onEditingChange}
+              hasPendingRemoteUpdate={hasPendingRemoteUpdate}
+              onOverwrite={onOverwrite}
             />
           </div>
         </div>
@@ -110,9 +116,15 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder
 function BodyElementRenderer({
   element,
   onUpdate,
+  onEditingChange,
+  hasPendingRemoteUpdate,
+  onOverwrite,
 }: {
   element: BodyElement;
   onUpdate?: (element: BodyElement) => void;
+  onEditingChange?: (isEditing: boolean) => void;
+  hasPendingRemoteUpdate?: boolean;
+  onOverwrite?: () => void;
 }) {
   switch (element.type) {
     case "heading":
@@ -125,6 +137,9 @@ function BodyElementRenderer({
               ? (text) => onUpdate({ ...element, text })
               : undefined
           }
+          onEditingChange={onEditingChange}
+          hasPendingRemoteUpdate={hasPendingRemoteUpdate}
+          onOverwrite={onOverwrite}
         />
       );
     case "paragraph":
@@ -136,6 +151,9 @@ function BodyElementRenderer({
               ? (text) => onUpdate({ ...element, text })
               : undefined
           }
+          onEditingChange={onEditingChange}
+          hasPendingRemoteUpdate={hasPendingRemoteUpdate}
+          onOverwrite={onOverwrite}
         />
       );
     case "chart":
@@ -164,18 +182,27 @@ function HeadingElement({
   level,
   text,
   onUpdate,
+  onEditingChange,
+  hasPendingRemoteUpdate,
+  onOverwrite,
 }: {
   level: number;
   text: string;
   onUpdate?: (text: string) => void;
+  onEditingChange?: (isEditing: boolean) => void;
+  hasPendingRemoteUpdate?: boolean;
+  onOverwrite?: () => void;
 }) {
   const [isEditing, setIsEditing] = useState(false);
   const [editText, setEditText] = useState(text);
   const inputRef = useRef<HTMLInputElement>(null);
 
   useEffect(() => {
-    setEditText(text);
-  }, [text]);
+    // Only update editText if not currently editing
+    if (!isEditing) {
+      setEditText(text);
+    }
+  }, [text, isEditing]);
 
   useEffect(() => {
     if (isEditing && inputRef.current) {
@@ -184,19 +211,52 @@ function HeadingElement({
     }
   }, [isEditing]);
 
+  const startEditing = () => {
+    setIsEditing(true);
+    onEditingChange?.(true);
+  };
+
+  const stopEditing = () => {
+    setIsEditing(false);
+    onEditingChange?.(false);
+  };
+
   const handleSave = () => {
+    // Don't auto-save if there's a pending remote update
+    if (hasPendingRemoteUpdate) return;
+
     if (onUpdate && editText.trim() !== text) {
       onUpdate(editText.trim());
     }
-    setIsEditing(false);
+    stopEditing();
+  };
+
+  const handleOverwrite = () => {
+    if (onUpdate && editText.trim() !== text) {
+      onUpdate(editText.trim());
+    }
+    onOverwrite?.();
+    stopEditing();
+  };
+
+  const handleCancel = () => {
+    setEditText(text);
+    stopEditing();
   };
 
   const handleKeyDown = (e: React.KeyboardEvent) => {
-    if (e.key === "Enter") {
+    // Disable Enter save if there's a pending remote update
+    if (e.key === "Enter" && !hasPendingRemoteUpdate) {
       handleSave();
     } else if (e.key === "Escape") {
-      setEditText(text);
-      setIsEditing(false);
+      handleCancel();
+    }
+  };
+
+  const handleBlur = () => {
+    // Don't auto-save on blur if there's a pending remote update
+    if (!hasPendingRemoteUpdate) {
+      handleSave();
     }
   };
 
@@ -213,15 +273,36 @@ function HeadingElement({
 
   if (isEditing && onUpdate) {
     return (
-      <input
-        ref={inputRef}
-        type="text"
-        value={editText}
-        onChange={(e) => setEditText(e.target.value)}
-        onBlur={handleSave}
-        onKeyDown={handleKeyDown}
-        className={`${baseClassName} ${sizeClass} w-full bg-transparent border-b border-[#3f6fb3] outline-none`}
-      />
+      <div>
+        <input
+          ref={inputRef}
+          type="text"
+          value={editText}
+          onChange={(e) => setEditText(e.target.value)}
+          onBlur={handleBlur}
+          onKeyDown={handleKeyDown}
+          className={`${baseClassName} ${sizeClass} w-full bg-transparent border-b border-[#3f6fb3] outline-none`}
+        />
+        {hasPendingRemoteUpdate && (
+          <div className="flex items-center gap-2 mt-2">
+            <span className="text-yellow-500 text-xs font-mono">Remote update pending</span>
+            <button
+              onClick={handleOverwrite}
+              onMouseDown={(e) => e.preventDefault()}
+              className="px-2 py-1 font-mono text-xs text-yellow-500 border border-yellow-500/50 hover:border-yellow-500 transition-colors"
+            >
+              Overwrite
+            </button>
+            <button
+              onClick={handleCancel}
+              onMouseDown={(e) => e.preventDefault()}
+              className="px-2 py-1 font-mono text-xs text-[#555] hover:text-white/70 transition-colors"
+            >
+              Cancel
+            </button>
+          </div>
+        )}
+      </div>
     );
   }
 
@@ -229,7 +310,7 @@ function HeadingElement({
   return (
     <HeadingTag
       className={`${baseClassName} ${sizeClass} ${onUpdate ? "cursor-text hover:bg-[rgba(117,170,252,0.05)] px-1 -mx-1 rounded" : ""}`}
-      onClick={() => onUpdate && setIsEditing(true)}
+      onClick={() => onUpdate && startEditing()}
     >
       {text}
     </HeadingTag>
@@ -239,17 +320,26 @@ function HeadingElement({
 function ParagraphElement({
   text,
   onUpdate,
+  onEditingChange,
+  hasPendingRemoteUpdate,
+  onOverwrite,
 }: {
   text: string;
   onUpdate?: (text: string) => void;
+  onEditingChange?: (isEditing: boolean) => void;
+  hasPendingRemoteUpdate?: boolean;
+  onOverwrite?: () => void;
 }) {
   const [isEditing, setIsEditing] = useState(false);
   const [editText, setEditText] = useState(text);
   const textareaRef = useRef<HTMLTextAreaElement>(null);
 
   useEffect(() => {
-    setEditText(text);
-  }, [text]);
+    // Only update editText if not currently editing
+    if (!isEditing) {
+      setEditText(text);
+    }
+  }, [text, isEditing]);
 
   useEffect(() => {
     if (isEditing && textareaRef.current) {
@@ -260,20 +350,52 @@ function ParagraphElement({
     }
   }, [isEditing]);
 
+  const startEditing = () => {
+    setIsEditing(true);
+    onEditingChange?.(true);
+  };
+
+  const stopEditing = () => {
+    setIsEditing(false);
+    onEditingChange?.(false);
+  };
+
   const handleSave = () => {
+    // Don't auto-save if there's a pending remote update
+    if (hasPendingRemoteUpdate) return;
+
     if (onUpdate && editText.trim() !== text) {
       onUpdate(editText.trim());
     }
-    setIsEditing(false);
+    stopEditing();
+  };
+
+  const handleOverwrite = () => {
+    if (onUpdate && editText.trim() !== text) {
+      onUpdate(editText.trim());
+    }
+    onOverwrite?.();
+    stopEditing();
+  };
+
+  const handleCancel = () => {
+    setEditText(text);
+    stopEditing();
   };
 
   const handleKeyDown = (e: React.KeyboardEvent) => {
     if (e.key === "Escape") {
-      setEditText(text);
-      setIsEditing(false);
+      handleCancel();
+    }
+    // Ctrl/Cmd + Enter to save - disabled if there's a pending remote update
+    if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && !hasPendingRemoteUpdate) {
+      handleSave();
     }
-    // Ctrl/Cmd + Enter to save
-    if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
+  };
+
+  const handleBlur = () => {
+    // Don't auto-save on blur if there's a pending remote update
+    if (!hasPendingRemoteUpdate) {
       handleSave();
     }
   };
@@ -292,13 +414,33 @@ function ParagraphElement({
           ref={textareaRef}
           value={editText}
           onChange={handleInput}
-          onBlur={handleSave}
+          onBlur={handleBlur}
           onKeyDown={handleKeyDown}
           className="font-mono text-sm text-white/80 leading-relaxed w-full bg-transparent border border-[#3f6fb3] outline-none p-2 resize-none min-h-[60px]"
         />
-        <div className="text-[10px] text-[#555] font-mono mt-1">
-          Ctrl+Enter to save, Esc to cancel
-        </div>
+        {hasPendingRemoteUpdate ? (
+          <div className="flex items-center gap-2 mt-2">
+            <span className="text-yellow-500 text-xs font-mono">Remote update pending</span>
+            <button
+              onClick={handleOverwrite}
+              onMouseDown={(e) => e.preventDefault()}
+              className="px-2 py-1 font-mono text-xs text-yellow-500 border border-yellow-500/50 hover:border-yellow-500 transition-colors"
+            >
+              Overwrite
+            </button>
+            <button
+              onClick={handleCancel}
+              onMouseDown={(e) => e.preventDefault()}
+              className="px-2 py-1 font-mono text-xs text-[#555] hover:text-white/70 transition-colors"
+            >
+              Cancel
+            </button>
+          </div>
+        ) : (
+          <div className="text-[10px] text-[#555] font-mono mt-1">
+            Ctrl+Enter to save, Esc to cancel
+          </div>
+        )}
       </div>
     );
   }
@@ -306,7 +448,7 @@ function ParagraphElement({
   return (
     <p
       className={`font-mono text-sm text-white/80 leading-relaxed ${onUpdate ? "cursor-text hover:bg-[rgba(117,170,252,0.05)] px-1 -mx-1 rounded" : ""}`}
-      onClick={() => onUpdate && setIsEditing(true)}
+      onClick={() => onUpdate && startEditing()}
     >
       {text}
     </p>
-- 
cgit v1.2.3

