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, 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, Task } from "../lib/api";
import { createTask } 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 [focusedElement, setFocusedElement] = useState<FocusedElement | null>(null);
const [suggestedPrompt, setSuggestedPrompt] = useState<string | null>(null);
const [createdTask, setCreatedTask] = useState<Task | 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);
setFocusedElement(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]
);
// Element action handlers for context menu
const handleBodyElementDelete = useCallback(
async (index: number) => {
if (fileDetail && id) {
const newBody = fileDetail.body.filter((_, i) => i !== index);
// Update local state immediately
setFileDetail({
...fileDetail,
body: newBody,
});
updateHasLocalChanges(true);
// Clear focus if deleting focused element
if (focusedElement?.index === index) {
setFocusedElement(null);
} else if (focusedElement && focusedElement.index > index) {
// Adjust focus index if deleting an element before it
setFocusedElement({
...focusedElement,
index: focusedElement.index - 1,
});
}
// Save to backend
pendingUpdateRef.current = true;
lastSentVersionRef.current = fileDetail.version;
try {
const result = await editFile(id, { 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, id, editFile, updateHasLocalChanges, focusedElement]
);
const handleBodyElementDuplicate = useCallback(
async (index: number) => {
if (fileDetail && id) {
const elementToDuplicate = fileDetail.body[index];
if (!elementToDuplicate) return;
const newBody = [...fileDetail.body];
// Insert duplicate after the original
newBody.splice(index + 1, 0, { ...elementToDuplicate });
// Update local state immediately
setFileDetail({
...fileDetail,
body: newBody,
});
updateHasLocalChanges(true);
// Adjust focus index if duplicating before focused element
if (focusedElement && focusedElement.index > index) {
setFocusedElement({
...focusedElement,
index: focusedElement.index + 1,
});
}
// Save to backend
pendingUpdateRef.current = true;
lastSentVersionRef.current = fileDetail.version;
try {
const result = await editFile(id, { 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, id, editFile, updateHasLocalChanges, focusedElement]
);
const handleFocusElement = useCallback((element: FocusedElement | null) => {
setFocusedElement(element);
}, []);
const handleClearFocus = useCallback(() => {
setFocusedElement(null);
}, []);
// Convert element to a different type
const handleConvertElement = useCallback(
async (index: number, toType: string) => {
if (!fileDetail || !id) return;
const element = fileDetail.body[index];
if (!element) return;
// Extract text content from current element
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; // Can't convert charts/images
}
// Create new element based on target type
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; // Unknown type
}
const newBody = [...fileDetail.body];
newBody[index] = newElement;
// Update local state
setFileDetail({ ...fileDetail, body: newBody });
updateHasLocalChanges(true);
// Update focus if this element was focused
if (focusedElement?.index === index) {
setFocusedElement({
index,
type: newElement.type,
preview: textContent.slice(0, 50) + (textContent.length > 50 ? "..." : ""),
});
}
// Save to backend
pendingUpdateRef.current = true;
lastSentVersionRef.current = fileDetail.version;
try {
const result = await editFile(id, { 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, id, editFile, updateHasLocalChanges, focusedElement]
);
// Generate from element - focus on it and pre-fill a prompt
const handleGenerateFromElement = useCallback(
(index: number, action: string) => {
if (!fileDetail) return;
const element = fileDetail.body[index];
if (!element) return;
// Get preview text
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";
}
// Focus on the element
setFocusedElement({
index,
type: element.type,
preview: preview + (preview.length >= 50 ? "..." : ""),
});
// Set suggested prompt based on action
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]
);
// Create a mesh task from an element
const handleCreateTaskFromElement = useCallback(
async (index: number, selectedText?: string) => {
if (!fileDetail) return;
const element = fileDetail.body[index];
if (!element) return;
// Get the content to use as task plan
let content = selectedText || "";
if (!content) {
switch (element.type) {
case "heading":
case "paragraph":
content = element.text;
break;
case "code":
content = element.content;
break;
case "list":
content = element.items.join("\n");
break;
default:
content = "Task from file element";
}
}
// Create a task name from the content
const name = content.slice(0, 60) + (content.length > 60 ? "..." : "");
try {
const task = await createTask({
name,
plan: content,
description: `Created from ${fileDetail.name}`,
});
setCreatedTask(task);
} catch (err) {
console.error("Failed to create task:", err);
}
},
[fileDetail]
);
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]);
const handleUploadMarkdown = useCallback(async (name: string, body: BodyElement[]) => {
if (creating) return;
setCreating(true);
try {
const newFile = await saveFile({
name,
transcript: [],
});
if (newFile) {
// Update with the parsed body
const updated = await editFile(newFile.id, { body, version: newFile.version });
if (updated) {
navigate(`/files/${updated.id}`);
} else {
navigate(`/files/${newFile.id}`);
}
}
} finally {
setCreating(false);
}
}, [creating, saveFile, editFile, 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}
onBodyElementDelete={handleBodyElementDelete}
onBodyElementDuplicate={handleBodyElementDuplicate}
onConvertElement={handleConvertElement}
onGenerateFromElement={handleGenerateFromElement}
onCreateTaskFromElement={handleCreateTaskFromElement}
onEditingChange={updateIsActivelyEditing}
hasPendingRemoteUpdate={!!remoteUpdate}
onOverwrite={handleRemoteUpdateDismiss}
focusedElement={focusedElement}
onFocusElement={handleFocusElement}
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}
focusedElement={focusedElement}
onClearFocus={handleClearFocus}
suggestedPrompt={suggestedPrompt}
onClearSuggestedPrompt={() => setSuggestedPrompt(null)}
/>
</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}
onUploadMarkdown={handleUploadMarkdown}
/>
)}
</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}
/>
)}
{/* Task created notification */}
{createdTask && (
<div className="fixed bottom-4 right-4 z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] p-4 shadow-lg max-w-sm">
<div className="flex items-start gap-3">
<span className="text-[#75aafc] text-lg">@</span>
<div className="flex-1">
<p className="font-mono text-xs text-[#9bc3ff] mb-1">Task created</p>
<p className="font-mono text-sm text-white truncate mb-3">
{createdTask.name}
</p>
<div className="flex gap-2">
<button
onClick={() => {
navigate(`/mesh/${createdTask.id}`);
setCreatedTask(null);
}}
className="px-3 py-1 font-mono text-xs text-[#0a1628] bg-[#75aafc] hover:bg-[#9bc3ff] transition-colors"
>
Go to task
</button>
<button
onClick={() => setCreatedTask(null)}
className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.3)] hover:border-[#75aafc] transition-colors"
>
Dismiss
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}