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(null); const [detailLoading, setDetailLoading] = useState(false); const [creating, setCreating] = useState(false); const [remoteUpdate, setRemoteUpdate] = useState(null); const [remoteFileData, setRemoteFileData] = useState(null); const pendingUpdateRef = useRef(false); // Track the last version we sent to detect our own updates const lastSentVersionRef = useRef(null); // Track the version we just successfully saved (to ignore its WebSocket notification) const lastSavedVersionRef = useRef(null); // Use refs for values checked in WebSocket callback to avoid stale closures const hasLocalChangesRef = useRef(false); const isActivelyEditingRef = useRef(false); const currentVersionRef = useRef(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 (
{error && (
{error}
)} {id && fileDetail ? (
) : id && detailLoading ? (
Loading...
) : ( )}
{/* Conflict notification */} {conflict?.hasConflict && ( )} {/* Remote update notification */} {remoteUpdate && ( )}
); }