From 2faba0388f93d8e4fb86219eba7883b331d501ff Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Wed, 24 Dec 2025 05:45:22 +0000
Subject: Add versioning to files

---
 makima/frontend/src/routes/files.tsx | 207 +++++++++++++++++++++++++++++------
 1 file changed, 171 insertions(+), 36 deletions(-)

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

diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx
index f398041..0d870f7 100644
--- a/makima/frontend/src/routes/files.tsx
+++ b/makima/frontend/src/routes/files.tsx
@@ -7,6 +7,7 @@ import { CliInput } from "../components/files/CliInput";
 import { ConflictNotification } from "../components/files/ConflictNotification";
 import { UpdateNotification } from "../components/files/UpdateNotification";
 import { useFiles } from "../hooks/useFiles";
+import { useVersionHistory } from "../hooks/useVersionHistory";
 import {
   useFileSubscription,
   type FileUpdateEvent,
@@ -22,37 +23,121 @@ export default function FilesPage() {
   const [creating, setCreating] = useState(false);
   const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null);
   const [remoteFileData, setRemoteFileData] = useState<FileDetailType | null>(null);
-  const [hasLocalChanges, setHasLocalChanges] = useState(false);
-  const [isActivelyEditing, setIsActivelyEditing] = useState(false);
   const pendingUpdateRef = useRef(false);
+  // Track the last version we sent to detect our own updates
+  const lastSentVersionRef = useRef<number | null>(null);
+  // Track the version we just successfully saved (to ignore its WebSocket notification)
+  const lastSavedVersionRef = useRef<number | null>(null);
+  // Use refs for values checked in WebSocket callback to avoid stale closures
+  const hasLocalChangesRef = useRef(false);
+  const isActivelyEditingRef = useRef(false);
+  const currentVersionRef = useRef<number | null>(null);
+
+  // Helper functions to update refs (used only in callbacks, not for rendering)
+  const updateHasLocalChanges = useCallback((value: boolean) => {
+    hasLocalChangesRef.current = value;
+  }, []);
+
+  const updateIsActivelyEditing = useCallback((value: boolean) => {
+    isActivelyEditingRef.current = value;
+  }, []);
+
+  // Version history
+  const {
+    versions,
+    loading: versionsLoading,
+    selectedVersion,
+    loadingVersion,
+    restoring,
+    fetchVersion,
+    restoreToVersion,
+    clearSelectedVersion,
+    fetchVersions,
+  } = useVersionHistory({
+    fileId: id || null,
+    currentVersion: fileDetail?.version || 0,
+  });
+
+  // Handle version restore
+  const handleRestoreVersion = useCallback(
+    async (targetVersion: number) => {
+      const result = await restoreToVersion(targetVersion);
+      if (result) {
+        currentVersionRef.current = result.version;
+        setFileDetail(result);
+        updateHasLocalChanges(false);
+        // Refresh version list after restore
+        fetchVersions();
+      }
+    },
+    [restoreToVersion, fetchVersions, updateHasLocalChanges]
+  );
 
   // Load file detail when URL has an id
   useEffect(() => {
     if (id) {
       setDetailLoading(true);
-      setHasLocalChanges(false);
+      updateHasLocalChanges(false);
+      // Reset pending update tracking when switching files
+      pendingUpdateRef.current = false;
+      lastSentVersionRef.current = null;
+      lastSavedVersionRef.current = null;
+      currentVersionRef.current = null;
+      setRemoteUpdate(null);
+      setRemoteFileData(null);
       fetchFile(id).then((detail) => {
+        if (detail) {
+          currentVersionRef.current = detail.version;
+        }
         setFileDetail(detail);
         setDetailLoading(false);
       });
     } else {
       setFileDetail(null);
-      setHasLocalChanges(false);
+      currentVersionRef.current = null;
+      updateHasLocalChanges(false);
     }
-  }, [id, fetchFile]);
+  }, [id, fetchFile, updateHasLocalChanges]);
 
   // Handle file update events from WebSocket
   const handleFileUpdate = useCallback(
     async (event: FileUpdateEvent) => {
-      // Ignore our own updates
+      // Check if this is a version we just saved - ignore it
+      // This handles the case where the WebSocket arrives after the HTTP response
+      if (lastSavedVersionRef.current !== null && event.version === lastSavedVersionRef.current) {
+        lastSavedVersionRef.current = null;
+        return;
+      }
+
+      // If we have a pending update, check if this is our own update
       if (pendingUpdateRef.current) {
-        pendingUpdateRef.current = false;
+        if (lastSentVersionRef.current !== null) {
+          const expectedNewVersion = lastSentVersionRef.current + 1;
+          if (event.version === expectedNewVersion) {
+            // This is our own update - ignore it
+            pendingUpdateRef.current = false;
+            lastSentVersionRef.current = null;
+            return;
+          }
+        }
+        // We sent an update but received a different version - could be a race condition
+        // Still ignore since we have an update in flight
+        return;
+      }
+
+      // Check if this version matches what we already have
+      // This catches cases where our save's WebSocket arrives late
+      if (currentVersionRef.current !== null && event.version === currentVersionRef.current) {
         return;
       }
 
       // If no local changes and not actively editing, auto-refresh
-      if (!hasLocalChanges && !isActivelyEditing) {
+      // Use refs to get current values (avoid stale closure issues)
+      if (!hasLocalChangesRef.current && !isActivelyEditingRef.current) {
         const detail = await fetchFile(event.fileId);
+        if (detail) {
+          currentVersionRef.current = detail.version;
+        }
         setFileDetail(detail);
       } else {
         // Fetch remote version for diff display
@@ -62,7 +147,7 @@ export default function FilesPage() {
         setRemoteUpdate(event);
       }
     },
-    [hasLocalChanges, isActivelyEditing, fetchFile]
+    [fetchFile]
   );
 
   // Subscribe to file updates
@@ -98,13 +183,22 @@ export default function FilesPage() {
     async (fileId: string, name: string, description: string) => {
       if (!fileDetail) return;
       pendingUpdateRef.current = true;
-      const result = await editFile(fileId, { name, description, version: fileDetail.version });
-      if (result) {
-        setFileDetail(result);
-        setHasLocalChanges(false);
+      lastSentVersionRef.current = fileDetail.version;
+      try {
+        const result = await editFile(fileId, { name, description, version: fileDetail.version });
+        if (result) {
+          // Track the saved version to ignore its WebSocket notification
+          lastSavedVersionRef.current = result.version;
+          currentVersionRef.current = result.version;
+          setFileDetail(result);
+          updateHasLocalChanges(false);
+        }
+      } finally {
+        pendingUpdateRef.current = false;
+        lastSentVersionRef.current = null;
       }
     },
-    [editFile, fileDetail]
+    [editFile, fileDetail, updateHasLocalChanges]
   );
 
   const handleBodyUpdate = useCallback(
@@ -132,18 +226,27 @@ export default function FilesPage() {
           ...fileDetail,
           body: newBody,
         });
-        setHasLocalChanges(true);
+        updateHasLocalChanges(true);
 
         // Save to backend with version for optimistic locking
         pendingUpdateRef.current = true;
-        const result = await editFile(id, { body: newBody, version: fileDetail.version });
-        if (result) {
-          setFileDetail(result);
-          setHasLocalChanges(false);
+        lastSentVersionRef.current = fileDetail.version;
+        try {
+          const result = await editFile(id, { body: newBody, version: fileDetail.version });
+          if (result) {
+            // Track the saved version to ignore its WebSocket notification
+            lastSavedVersionRef.current = result.version;
+            currentVersionRef.current = result.version;
+            setFileDetail(result);
+            updateHasLocalChanges(false);
+          }
+        } finally {
+          pendingUpdateRef.current = false;
+          lastSentVersionRef.current = null;
         }
       }
     },
-    [fileDetail, id, editFile]
+    [fileDetail, id, editFile, updateHasLocalChanges]
   );
 
   const handleBodyReorder = useCallback(
@@ -159,18 +262,27 @@ export default function FilesPage() {
           ...fileDetail,
           body: newBody,
         });
-        setHasLocalChanges(true);
+        updateHasLocalChanges(true);
 
         // Save to backend with version for optimistic locking
         pendingUpdateRef.current = true;
-        const result = await editFile(id, { body: newBody, version: fileDetail.version });
-        if (result) {
-          setFileDetail(result);
-          setHasLocalChanges(false);
+        lastSentVersionRef.current = fileDetail.version;
+        try {
+          const result = await editFile(id, { body: newBody, version: fileDetail.version });
+          if (result) {
+            // Track the saved version to ignore its WebSocket notification
+            lastSavedVersionRef.current = result.version;
+            currentVersionRef.current = result.version;
+            setFileDetail(result);
+            updateHasLocalChanges(false);
+          }
+        } finally {
+          pendingUpdateRef.current = false;
+          lastSentVersionRef.current = null;
         }
       }
     },
-    [fileDetail, id, editFile]
+    [fileDetail, id, editFile, updateHasLocalChanges]
   );
 
   const handleCreate = useCallback(async () => {
@@ -194,10 +306,13 @@ export default function FilesPage() {
     if (id) {
       clearConflict();
       const detail = await fetchFile(id);
+      if (detail) {
+        currentVersionRef.current = detail.version;
+      }
       setFileDetail(detail);
-      setHasLocalChanges(false);
+      updateHasLocalChanges(false);
     }
-  }, [id, clearConflict, fetchFile]);
+  }, [id, clearConflict, fetchFile, updateHasLocalChanges]);
 
   const handleConflictForceOverwrite = useCallback(async () => {
     if (id && fileDetail) {
@@ -207,25 +322,37 @@ export default function FilesPage() {
       if (latest) {
         // Retry with latest version
         pendingUpdateRef.current = true;
-        const result = await editFile(id, { body: fileDetail.body, version: latest.version });
-        if (result) {
-          setFileDetail(result);
-          setHasLocalChanges(false);
+        lastSentVersionRef.current = latest.version;
+        try {
+          const result = await editFile(id, { body: fileDetail.body, version: latest.version });
+          if (result) {
+            // Track the saved version to ignore its WebSocket notification
+            lastSavedVersionRef.current = result.version;
+            currentVersionRef.current = result.version;
+            setFileDetail(result);
+            updateHasLocalChanges(false);
+          }
+        } finally {
+          pendingUpdateRef.current = false;
+          lastSentVersionRef.current = null;
         }
       }
     }
-  }, [id, fileDetail, clearConflict, fetchFile, editFile]);
+  }, [id, fileDetail, clearConflict, fetchFile, editFile, updateHasLocalChanges]);
 
   // Remote update handlers
   const handleRemoteUpdateRefresh = useCallback(async () => {
     if (id) {
       const detail = await fetchFile(id);
+      if (detail) {
+        currentVersionRef.current = detail.version;
+      }
       setFileDetail(detail);
       setRemoteUpdate(null);
       setRemoteFileData(null);
-      setHasLocalChanges(false);
+      updateHasLocalChanges(false);
     }
-  }, [id, fetchFile]);
+  }, [id, fetchFile, updateHasLocalChanges]);
 
   const handleRemoteUpdateDismiss = useCallback(() => {
     setRemoteUpdate(null);
@@ -254,9 +381,17 @@ export default function FilesPage() {
                 onDelete={handleDelete}
                 onBodyElementUpdate={handleBodyElementUpdate}
                 onBodyReorder={handleBodyReorder}
-                onEditingChange={setIsActivelyEditing}
+                onEditingChange={updateIsActivelyEditing}
                 hasPendingRemoteUpdate={!!remoteUpdate}
                 onOverwrite={handleRemoteUpdateDismiss}
+                versions={versions}
+                versionsLoading={versionsLoading}
+                selectedVersion={selectedVersion}
+                loadingVersion={loadingVersion}
+                restoring={restoring}
+                onSelectVersion={fetchVersion}
+                onRestoreVersion={handleRestoreVersion}
+                onClearVersionSelection={clearSelectedVersion}
               />
             </div>
             <div className="shrink-0">
-- 
cgit v1.2.3

