diff options
Diffstat (limited to 'makima/frontend/src/routes')
| -rw-r--r-- | makima/frontend/src/routes/files.tsx | 207 |
1 files changed, 171 insertions, 36 deletions
diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx index f398041..0d870f7 100644 --- a/makima/frontend/src/routes/files.tsx +++ b/makima/frontend/src/routes/files.tsx @@ -7,6 +7,7 @@ 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, @@ -22,37 +23,121 @@ export default function FilesPage() { const [creating, setCreating] = useState(false); const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null); const [remoteFileData, setRemoteFileData] = useState<FileDetailType | null>(null); - const [hasLocalChanges, setHasLocalChanges] = useState(false); - const [isActivelyEditing, setIsActivelyEditing] = useState(false); 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); - setHasLocalChanges(false); + 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); - setHasLocalChanges(false); + currentVersionRef.current = null; + updateHasLocalChanges(false); } - }, [id, fetchFile]); + }, [id, fetchFile, updateHasLocalChanges]); // Handle file update events from WebSocket const handleFileUpdate = useCallback( async (event: FileUpdateEvent) => { - // Ignore our own updates + // 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) { - pendingUpdateRef.current = false; + 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 - if (!hasLocalChanges && !isActivelyEditing) { + // 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 @@ -62,7 +147,7 @@ export default function FilesPage() { setRemoteUpdate(event); } }, - [hasLocalChanges, isActivelyEditing, fetchFile] + [fetchFile] ); // Subscribe to file updates @@ -98,13 +183,22 @@ export default function FilesPage() { async (fileId: string, name: string, description: string) => { if (!fileDetail) return; pendingUpdateRef.current = true; - const result = await editFile(fileId, { name, description, version: fileDetail.version }); - if (result) { - setFileDetail(result); - setHasLocalChanges(false); + 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] + [editFile, fileDetail, updateHasLocalChanges] ); const handleBodyUpdate = useCallback( @@ -132,18 +226,27 @@ export default function FilesPage() { ...fileDetail, body: newBody, }); - setHasLocalChanges(true); + updateHasLocalChanges(true); // Save to backend with version for optimistic locking pendingUpdateRef.current = true; - const result = await editFile(id, { body: newBody, version: fileDetail.version }); - if (result) { - setFileDetail(result); - setHasLocalChanges(false); + 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] + [fileDetail, id, editFile, updateHasLocalChanges] ); const handleBodyReorder = useCallback( @@ -159,18 +262,27 @@ export default function FilesPage() { ...fileDetail, body: newBody, }); - setHasLocalChanges(true); + updateHasLocalChanges(true); // Save to backend with version for optimistic locking pendingUpdateRef.current = true; - const result = await editFile(id, { body: newBody, version: fileDetail.version }); - if (result) { - setFileDetail(result); - setHasLocalChanges(false); + 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] + [fileDetail, id, editFile, updateHasLocalChanges] ); const handleCreate = useCallback(async () => { @@ -194,10 +306,13 @@ export default function FilesPage() { if (id) { clearConflict(); const detail = await fetchFile(id); + if (detail) { + currentVersionRef.current = detail.version; + } setFileDetail(detail); - setHasLocalChanges(false); + updateHasLocalChanges(false); } - }, [id, clearConflict, fetchFile]); + }, [id, clearConflict, fetchFile, updateHasLocalChanges]); const handleConflictForceOverwrite = useCallback(async () => { if (id && fileDetail) { @@ -207,25 +322,37 @@ export default function FilesPage() { if (latest) { // Retry with latest version pendingUpdateRef.current = true; - const result = await editFile(id, { body: fileDetail.body, version: latest.version }); - if (result) { - setFileDetail(result); - setHasLocalChanges(false); + 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]); + }, [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); - setHasLocalChanges(false); + updateHasLocalChanges(false); } - }, [id, fetchFile]); + }, [id, fetchFile, updateHasLocalChanges]); const handleRemoteUpdateDismiss = useCallback(() => { setRemoteUpdate(null); @@ -254,9 +381,17 @@ export default function FilesPage() { onDelete={handleDelete} onBodyElementUpdate={handleBodyElementUpdate} onBodyReorder={handleBodyReorder} - onEditingChange={setIsActivelyEditing} + 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"> |
