summaryrefslogblamecommitdiff
path: root/makima/frontend/src/routes/files.tsx
blob: 0d870f7b471966a0ea0e68605eb92117f87e70a7 (plain) (tree)
1
2
3
4
5
6
7
8
9
                                                                 



                                                            
                                                        

                                                                                
                                             
                                                               



                                      
                                                                            



                                             
                                                                                                                   

                                                                            
                                                  
                                                                                 
                                                                                    
                                         















































                                                                                        




                                        







                                                           
                                      


                                                     




                                

                                       
     
                                             
 


                                             







                                                                                                  
                                     
















                                                                                              


               
                                                                   

                                                                         
                                                     


                                                     

                              


                                                         



                                                
               







                               
























                                                                  

                                      












                                                                                                  
       
      
                                                 

    












                                                      











                                                            
                                    
 

                                                              












                                                                                            
         

       
                                                     














                                                            
                                    
 

                                                              












                                                                                            
         

       
                                                     

















                                                            




                                                        


                                                   
                            
                                   
     
                                                            








                                                                












                                                                                                


         
                                                                                  




                                                             


                                                   

                            
                              
                                   
     
                                             


                                                       
                            

         



                                                                          
                                                                                
                   
                                                                                                                   




                             







                                                                        

                                                             
                                                         

                                                       







                                                              





                                                                  






                                                                              
                                         

                                       
                                   


             













                                                         

                                                 



                                               


          
import { useState, useCallback, useEffect, useRef } from "react";
import { useParams, useNavigate } from "react-router";
import { Masthead } from "../components/Masthead";
import { FileList } from "../components/files/FileList";
import { FileDetail } from "../components/files/FileDetail";
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,
} from "../hooks/useFileSubscription";
import type { FileDetail as FileDetailType, BodyElement } from "../lib/api";

export default function FilesPage() {
  const { id } = useParams<{ id: string }>();
  const navigate = useNavigate();
  const { files, loading, error, conflict, clearConflict, fetchFile, editFile, removeFile, saveFile } = useFiles();
  const [fileDetail, setFileDetail] = useState<FileDetailType | null>(null);
  const [detailLoading, setDetailLoading] = useState(false);
  const [creating, setCreating] = useState(false);
  const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null);
  const [remoteFileData, setRemoteFileData] = useState<FileDetailType | null>(null);
  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);
      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);
      currentVersionRef.current = null;
      updateHasLocalChanges(false);
    }
  }, [id, fetchFile, updateHasLocalChanges]);

  // Handle file update events from WebSocket
  const handleFileUpdate = useCallback(
    async (event: FileUpdateEvent) => {
      // 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) {
        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
      // 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
        const remoteData = await fetchFile(event.fileId);
        setRemoteFileData(remoteData);
        // Show notification about remote update
        setRemoteUpdate(event);
      }
    },
    [fetchFile]
  );

  // Subscribe to file updates
  useFileSubscription({
    fileId: id || null,
    onUpdate: handleFileUpdate,
  });

  const handleSelectFile = useCallback(
    (fileId: string) => {
      navigate(`/files/${fileId}`);
    },
    [navigate]
  );

  const handleBack = useCallback(() => {
    navigate("/files");
  }, [navigate]);

  const handleDelete = useCallback(
    async (fileId: string) => {
      if (confirm("Are you sure you want to delete this file?")) {
        const success = await removeFile(fileId);
        if (success && id === fileId) {
          navigate("/files");
        }
      }
    },
    [removeFile, id, navigate]
  );

  const handleSave = useCallback(
    async (fileId: string, name: string, description: string) => {
      if (!fileDetail) return;
      pendingUpdateRef.current = true;
      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, updateHasLocalChanges]
  );

  const handleBodyUpdate = useCallback(
    (body: BodyElement[], summary: string | null) => {
      if (fileDetail) {
        setFileDetail({
          ...fileDetail,
          body,
          summary,
        });
      }
    },
    [fileDetail]
  );

  const handleBodyElementUpdate = useCallback(
    async (index: number, element: BodyElement) => {
      if (fileDetail && id) {
        // Create new body array with updated element
        const newBody = [...fileDetail.body];
        newBody[index] = element;

        // Update local state immediately for responsiveness
        setFileDetail({
          ...fileDetail,
          body: newBody,
        });
        updateHasLocalChanges(true);

        // Save to backend with version for optimistic locking
        pendingUpdateRef.current = true;
        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, updateHasLocalChanges]
  );

  const handleBodyReorder = useCallback(
    async (fromIndex: number, toIndex: number) => {
      if (fileDetail && id) {
        // Create new body array with reordered elements
        const newBody = [...fileDetail.body];
        const [movedElement] = newBody.splice(fromIndex, 1);
        newBody.splice(toIndex, 0, movedElement);

        // Update local state immediately for responsiveness
        setFileDetail({
          ...fileDetail,
          body: newBody,
        });
        updateHasLocalChanges(true);

        // Save to backend with version for optimistic locking
        pendingUpdateRef.current = true;
        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, updateHasLocalChanges]
  );

  const handleCreate = useCallback(async () => {
    if (creating) return;
    setCreating(true);
    try {
      const newFile = await saveFile({
        name: `Untitled ${new Date().toLocaleDateString()}`,
        transcript: [],
      });
      if (newFile) {
        navigate(`/files/${newFile.id}`);
      }
    } finally {
      setCreating(false);
    }
  }, [creating, saveFile, navigate]);

  // Conflict resolution handlers
  const handleConflictReload = useCallback(async () => {
    if (id) {
      clearConflict();
      const detail = await fetchFile(id);
      if (detail) {
        currentVersionRef.current = detail.version;
      }
      setFileDetail(detail);
      updateHasLocalChanges(false);
    }
  }, [id, clearConflict, fetchFile, updateHasLocalChanges]);

  const handleConflictForceOverwrite = useCallback(async () => {
    if (id && fileDetail) {
      clearConflict();
      // Fetch latest version first
      const latest = await fetchFile(id);
      if (latest) {
        // Retry with latest version
        pendingUpdateRef.current = true;
        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, 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);
      updateHasLocalChanges(false);
    }
  }, [id, fetchFile, updateHasLocalChanges]);

  const handleRemoteUpdateDismiss = useCallback(() => {
    setRemoteUpdate(null);
    setRemoteFileData(null);
  }, []);

  return (
    <div className="relative z-10 h-screen flex flex-col overflow-hidden">
      <Masthead showTicker={false} showNav />

      <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden flex flex-col">
        {error && (
          <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm shrink-0">
            {error}
          </div>
        )}

        {id && fileDetail ? (
          <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
            <div className="flex-1 min-h-0 overflow-hidden">
              <FileDetail
                file={fileDetail}
                loading={detailLoading}
                onBack={handleBack}
                onSave={handleSave}
                onDelete={handleDelete}
                onBodyElementUpdate={handleBodyElementUpdate}
                onBodyReorder={handleBodyReorder}
                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">
              <CliInput fileId={id} onUpdate={handleBodyUpdate} />
            </div>
          </div>
        ) : id && detailLoading ? (
          <div className="panel h-full flex items-center justify-center">
            <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div>
          </div>
        ) : (
          <FileList
            files={files}
            loading={loading || creating}
            onSelect={handleSelectFile}
            onDelete={handleDelete}
            onCreate={handleCreate}
          />
        )}
      </main>

      {/* Conflict notification */}
      {conflict?.hasConflict && (
        <ConflictNotification
          onReload={handleConflictReload}
          onForceOverwrite={handleConflictForceOverwrite}
          onDismiss={clearConflict}
        />
      )}

      {/* Remote update notification */}
      {remoteUpdate && (
        <UpdateNotification
          updatedBy={remoteUpdate.updatedBy}
          localBody={fileDetail?.body || []}
          remoteBody={remoteFileData?.body || []}
          onRefresh={handleRemoteUpdateRefresh}
          onDismiss={handleRemoteUpdateDismiss}
        />
      )}
    </div>
  );
}