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 (
);
}
// 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 ? (
) : (
File not found
)}
{/* Conflict notification */}
{conflict?.hasConflict && (
)}
{/* Remote update notification */}
{remoteUpdate && (
)}
);
}