import { useEffect, useState, useCallback, useRef } from "react"; import { useParams, useNavigate } from "react-router"; import { useAuth } from "../contexts/AuthContext"; import { Masthead } from "../components/Masthead"; import { FileDetail, type FocusedElement } 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"; /** * ContractFilePage - Wrapper for viewing files within a contract context * * This component handles the /contracts/:contractId/files/:fileId route, * providing navigation back to the contract and rendering the file detail view. */ export default function ContractFilePage() { const { id: contractId, fileId } = useParams<{ id: string; fileId: string }>(); const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); const navigate = useNavigate(); // Redirect to login if not authenticated (when auth is configured) useEffect(() => { if (!authLoading && isAuthConfigured && !isAuthenticated) { navigate("/login"); } }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); // Show loading while checking auth if (authLoading) { return (

Loading...

); } // Don't render if not authenticated (will redirect) if (isAuthConfigured && !isAuthenticated) { return null; } // Render the file page with contract context return ; } // A version of the files page aware of contract context function ContractAwareFilesPage({ contractId, fileId, }: { contractId?: string; fileId?: string; }) { const navigate = useNavigate(); const { error, conflict, clearConflict, fetchFile, editFile, removeFile } = useFiles(); const [fileDetail, setFileDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [remoteUpdate, setRemoteUpdate] = useState(null); const [remoteFileData, setRemoteFileData] = useState(null); const [focusedElement, setFocusedElement] = useState(null); const [suggestedPrompt, setSuggestedPrompt] = useState(null); const pendingUpdateRef = useRef(false); const lastSentVersionRef = useRef(null); const lastSavedVersionRef = useRef(null); const hasLocalChangesRef = useRef(false); const isActivelyEditingRef = useRef(false); const currentVersionRef = useRef(null); // Handle back navigation - go to contract detail instead of /files const handleBack = useCallback(() => { if (contractId) { navigate(`/contracts/${contractId}`); } else { navigate("/contracts"); } }, [contractId, navigate]); 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: fileId || null, currentVersion: fileDetail?.version || 0, }); const handleRestoreVersion = useCallback( async (targetVersion: number) => { const result = await restoreToVersion(targetVersion); if (result) { currentVersionRef.current = result.version; setFileDetail(result); updateHasLocalChanges(false); fetchVersions(); } }, [restoreToVersion, fetchVersions, updateHasLocalChanges] ); // Load file detail when fileId is provided useEffect(() => { if (fileId) { setDetailLoading(true); updateHasLocalChanges(false); pendingUpdateRef.current = false; lastSentVersionRef.current = null; lastSavedVersionRef.current = null; currentVersionRef.current = null; setRemoteUpdate(null); setRemoteFileData(null); setFocusedElement(null); fetchFile(fileId).then((detail) => { if (detail) { currentVersionRef.current = detail.version; } setFileDetail(detail); setDetailLoading(false); }); } else { setFileDetail(null); currentVersionRef.current = null; updateHasLocalChanges(false); } }, [fileId, fetchFile, updateHasLocalChanges]); // Handle file update events from WebSocket const handleFileUpdate = useCallback( async (event: FileUpdateEvent) => { if (lastSavedVersionRef.current !== null && event.version === lastSavedVersionRef.current) { lastSavedVersionRef.current = null; return; } if (pendingUpdateRef.current) { if (lastSentVersionRef.current !== null) { const expectedNewVersion = lastSentVersionRef.current + 1; if (event.version === expectedNewVersion) { pendingUpdateRef.current = false; lastSentVersionRef.current = null; return; } } return; } if (currentVersionRef.current !== null && event.version === currentVersionRef.current) { return; } if (!hasLocalChangesRef.current && !isActivelyEditingRef.current) { const detail = await fetchFile(event.fileId); if (detail) { currentVersionRef.current = detail.version; } setFileDetail(detail); } else { const remoteData = await fetchFile(event.fileId); setRemoteFileData(remoteData); setRemoteUpdate(event); } }, [fetchFile] ); useFileSubscription({ fileId: fileId || null, onUpdate: handleFileUpdate, }); const handleDelete = useCallback( async (id: string) => { if (confirm("Are you sure you want to delete this file?")) { const success = await removeFile(id); if (success && fileId === id) { handleBack(); } } }, [removeFile, fileId, handleBack] ); const handleSave = useCallback( async (id: string, name: string, description: string) => { if (!fileDetail) return; pendingUpdateRef.current = true; lastSentVersionRef.current = fileDetail.version; try { const result = await editFile(id, { name, description, version: fileDetail.version }); if (result) { 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 && fileId) { const newBody = [...fileDetail.body]; newBody[index] = element; setFileDetail({ ...fileDetail, body: newBody, }); updateHasLocalChanges(true); pendingUpdateRef.current = true; lastSentVersionRef.current = fileDetail.version; try { const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); if (result) { lastSavedVersionRef.current = result.version; currentVersionRef.current = result.version; setFileDetail(result); updateHasLocalChanges(false); } } finally { pendingUpdateRef.current = false; lastSentVersionRef.current = null; } } }, [fileDetail, fileId, editFile, updateHasLocalChanges] ); const handleBodyReorder = useCallback( async (fromIndex: number, toIndex: number) => { if (fileDetail && fileId) { const newBody = [...fileDetail.body]; const [movedElement] = newBody.splice(fromIndex, 1); newBody.splice(toIndex, 0, movedElement); setFileDetail({ ...fileDetail, body: newBody, }); updateHasLocalChanges(true); pendingUpdateRef.current = true; lastSentVersionRef.current = fileDetail.version; try { const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); if (result) { lastSavedVersionRef.current = result.version; currentVersionRef.current = result.version; setFileDetail(result); updateHasLocalChanges(false); } } finally { pendingUpdateRef.current = false; lastSentVersionRef.current = null; } } }, [fileDetail, fileId, editFile, updateHasLocalChanges] ); const handleBodyElementDelete = useCallback( async (index: number) => { if (fileDetail && fileId) { const newBody = fileDetail.body.filter((_, i) => i !== index); setFileDetail({ ...fileDetail, body: newBody, }); updateHasLocalChanges(true); if (focusedElement?.index === index) { setFocusedElement(null); } else if (focusedElement && focusedElement.index > index) { setFocusedElement({ ...focusedElement, index: focusedElement.index - 1, }); } pendingUpdateRef.current = true; lastSentVersionRef.current = fileDetail.version; try { const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); if (result) { lastSavedVersionRef.current = result.version; currentVersionRef.current = result.version; setFileDetail(result); updateHasLocalChanges(false); } } finally { pendingUpdateRef.current = false; lastSentVersionRef.current = null; } } }, [fileDetail, fileId, editFile, updateHasLocalChanges, focusedElement] ); const handleBodyElementDuplicate = useCallback( async (index: number) => { if (fileDetail && fileId) { const elementToDuplicate = fileDetail.body[index]; if (!elementToDuplicate) return; const newBody = [...fileDetail.body]; newBody.splice(index + 1, 0, { ...elementToDuplicate }); setFileDetail({ ...fileDetail, body: newBody, }); updateHasLocalChanges(true); if (focusedElement && focusedElement.index > index) { setFocusedElement({ ...focusedElement, index: focusedElement.index + 1, }); } pendingUpdateRef.current = true; lastSentVersionRef.current = fileDetail.version; try { const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); if (result) { lastSavedVersionRef.current = result.version; currentVersionRef.current = result.version; setFileDetail(result); updateHasLocalChanges(false); } } finally { pendingUpdateRef.current = false; lastSentVersionRef.current = null; } } }, [fileDetail, fileId, editFile, updateHasLocalChanges, focusedElement] ); const handleFocusElement = useCallback((element: FocusedElement | null) => { setFocusedElement(element); }, []); const handleClearFocus = useCallback(() => { setFocusedElement(null); }, []); const handleConvertElement = useCallback( async (index: number, toType: string) => { if (!fileDetail || !fileId) return; const element = fileDetail.body[index]; if (!element) return; let textContent = ""; switch (element.type) { case "heading": case "paragraph": textContent = element.text; break; case "code": textContent = element.content; break; case "list": textContent = element.items.join("\n"); break; default: return; } let newElement: BodyElement; if (toType === "paragraph") { newElement = { type: "paragraph", text: textContent }; } else if (toType === "list_unordered") { const items = textContent.split("\n").filter(line => line.trim()); newElement = { type: "list", ordered: false, items }; } else if (toType === "list_ordered") { const items = textContent.split("\n").filter(line => line.trim()); newElement = { type: "list", ordered: true, items }; } else if (toType === "code") { newElement = { type: "code", content: textContent }; } else if (toType.startsWith("heading_")) { const level = parseInt(toType.replace("heading_", ""), 10) as 1 | 2 | 3 | 4 | 5 | 6; newElement = { type: "heading", level, text: textContent }; } else { return; } const newBody = [...fileDetail.body]; newBody[index] = newElement; setFileDetail({ ...fileDetail, body: newBody }); updateHasLocalChanges(true); if (focusedElement?.index === index) { setFocusedElement({ index, type: newElement.type, preview: textContent.slice(0, 50) + (textContent.length > 50 ? "..." : ""), }); } pendingUpdateRef.current = true; lastSentVersionRef.current = fileDetail.version; try { const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); if (result) { lastSavedVersionRef.current = result.version; currentVersionRef.current = result.version; setFileDetail(result); updateHasLocalChanges(false); } } finally { pendingUpdateRef.current = false; lastSentVersionRef.current = null; } }, [fileDetail, fileId, editFile, updateHasLocalChanges, focusedElement] ); const handleGenerateFromElement = useCallback( (index: number, action: string) => { if (!fileDetail) return; const element = fileDetail.body[index]; if (!element) return; let preview = ""; switch (element.type) { case "heading": case "paragraph": preview = element.text.slice(0, 50); break; case "code": preview = element.content.slice(0, 50); break; case "list": preview = element.items[0]?.slice(0, 40) || ""; break; default: preview = "Element"; } setFocusedElement({ index, type: element.type, preview: preview + (preview.length >= 50 ? "..." : ""), }); let prompt = ""; switch (action) { case "elaborate": prompt = "Elaborate and expand on this content"; break; case "summarize": prompt = "Summarize this content"; break; case "extract_actions": prompt = "Extract action items from this content"; break; } setSuggestedPrompt(prompt); }, [fileDetail] ); // Conflict resolution handlers const handleConflictReload = useCallback(async () => { if (fileId) { clearConflict(); const detail = await fetchFile(fileId); if (detail) { currentVersionRef.current = detail.version; } setFileDetail(detail); updateHasLocalChanges(false); } }, [fileId, clearConflict, fetchFile, updateHasLocalChanges]); const handleConflictForceOverwrite = useCallback(async () => { if (fileId && fileDetail) { clearConflict(); const latest = await fetchFile(fileId); if (latest) { pendingUpdateRef.current = true; lastSentVersionRef.current = latest.version; try { const result = await editFile(fileId, { body: fileDetail.body, version: latest.version }); if (result) { lastSavedVersionRef.current = result.version; currentVersionRef.current = result.version; setFileDetail(result); updateHasLocalChanges(false); } } finally { pendingUpdateRef.current = false; lastSentVersionRef.current = null; } } } }, [fileId, fileDetail, clearConflict, fetchFile, editFile, updateHasLocalChanges]); const handleRemoteUpdateRefresh = useCallback(async () => { if (fileId) { const detail = await fetchFile(fileId); if (detail) { currentVersionRef.current = detail.version; } setFileDetail(detail); setRemoteUpdate(null); setRemoteFileData(null); updateHasLocalChanges(false); } }, [fileId, fetchFile, updateHasLocalChanges]); const handleRemoteUpdateDismiss = useCallback(() => { setRemoteUpdate(null); setRemoteFileData(null); }, []); return (
{error && (
{error}
)} {fileId && fileDetail ? (
setSuggestedPrompt(null)} />
) : fileId && detailLoading ? (
Loading...
) : (

File not found

)}
{/* Conflict notification */} {conflict?.hasConflict && ( )} {/* Remote update notification */} {remoteUpdate && ( )}
); }