summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2025-12-23 22:20:52 +0000
committersoryu <soryu@soryu.co>2025-12-23 22:20:52 +0000
commit72c2590571104b8d10e3f72d7a5b984d0b520c51 (patch)
tree735aa03056a44a93b9abdf915545ad034ee2b597 /makima/frontend/src/routes
parentf5222a7ae5ade5589436778cb01fc0abe625b3c3 (diff)
downloadsoryu-72c2590571104b8d10e3f72d7a5b984d0b520c51.tar.gz
soryu-72c2590571104b8d10e3f72d7a5b984d0b520c51.zip
Add conflict notification and file update WS endpoint
Diffstat (limited to 'makima/frontend/src/routes')
-rw-r--r--makima/frontend/src/routes/files.tsx133
1 files changed, 123 insertions, 10 deletions
diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx
index 79544c5..423baa1 100644
--- a/makima/frontend/src/routes/files.tsx
+++ b/makima/frontend/src/routes/files.tsx
@@ -1,33 +1,71 @@
-import { useState, useCallback, useEffect } from "react";
+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 {
+ 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, fetchFile, editFile, removeFile, saveFile } = useFiles();
+ 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 [hasLocalChanges, setHasLocalChanges] = useState(false);
+ const pendingUpdateRef = useRef(false);
// Load file detail when URL has an id
useEffect(() => {
if (id) {
setDetailLoading(true);
+ setHasLocalChanges(false);
fetchFile(id).then((detail) => {
setFileDetail(detail);
setDetailLoading(false);
});
} else {
setFileDetail(null);
+ setHasLocalChanges(false);
}
}, [id, fetchFile]);
+ // Handle file update events from WebSocket
+ const handleFileUpdate = useCallback(
+ async (event: FileUpdateEvent) => {
+ // Ignore our own updates
+ if (pendingUpdateRef.current) {
+ pendingUpdateRef.current = false;
+ return;
+ }
+
+ // If no local changes, auto-refresh
+ if (!hasLocalChanges) {
+ const detail = await fetchFile(event.fileId);
+ setFileDetail(detail);
+ } else {
+ // Show notification about remote update
+ setRemoteUpdate(event);
+ }
+ },
+ [hasLocalChanges, fetchFile]
+ );
+
+ // Subscribe to file updates
+ useFileSubscription({
+ fileId: id || null,
+ onUpdate: handleFileUpdate,
+ });
+
const handleSelectFile = useCallback(
(fileId: string) => {
navigate(`/files/${fileId}`);
@@ -53,11 +91,15 @@ export default function FilesPage() {
const handleSave = useCallback(
async (fileId: string, name: string, description: string) => {
- await editFile(fileId, { name, description });
- const detail = await fetchFile(fileId);
- setFileDetail(detail);
+ if (!fileDetail) return;
+ pendingUpdateRef.current = true;
+ const result = await editFile(fileId, { name, description, version: fileDetail.version });
+ if (result) {
+ setFileDetail(result);
+ setHasLocalChanges(false);
+ }
},
- [editFile, fetchFile]
+ [editFile, fileDetail]
);
const handleBodyUpdate = useCallback(
@@ -85,9 +127,15 @@ export default function FilesPage() {
...fileDetail,
body: newBody,
});
+ setHasLocalChanges(true);
- // Save to backend
- await editFile(id, { body: newBody });
+ // 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);
+ }
}
},
[fileDetail, id, editFile]
@@ -106,9 +154,15 @@ export default function FilesPage() {
...fileDetail,
body: newBody,
});
+ setHasLocalChanges(true);
- // Save to backend
- await editFile(id, { body: newBody });
+ // 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);
+ }
}
},
[fileDetail, id, editFile]
@@ -130,6 +184,47 @@ export default function FilesPage() {
}
}, [creating, saveFile, navigate]);
+ // Conflict resolution handlers
+ const handleConflictReload = useCallback(async () => {
+ if (id) {
+ clearConflict();
+ const detail = await fetchFile(id);
+ setFileDetail(detail);
+ setHasLocalChanges(false);
+ }
+ }, [id, clearConflict, fetchFile]);
+
+ 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;
+ const result = await editFile(id, { body: fileDetail.body, version: latest.version });
+ if (result) {
+ setFileDetail(result);
+ setHasLocalChanges(false);
+ }
+ }
+ }
+ }, [id, fileDetail, clearConflict, fetchFile, editFile]);
+
+ // Remote update handlers
+ const handleRemoteUpdateRefresh = useCallback(async () => {
+ if (id) {
+ const detail = await fetchFile(id);
+ setFileDetail(detail);
+ setRemoteUpdate(null);
+ setHasLocalChanges(false);
+ }
+ }, [id, fetchFile]);
+
+ const handleRemoteUpdateDismiss = useCallback(() => {
+ setRemoteUpdate(null);
+ }, []);
+
return (
<div className="relative z-10 h-screen flex flex-col overflow-hidden">
<Masthead showTicker={false} showNav />
@@ -172,6 +267,24 @@ export default function FilesPage() {
/>
)}
</main>
+
+ {/* Conflict notification */}
+ {conflict?.hasConflict && (
+ <ConflictNotification
+ onReload={handleConflictReload}
+ onForceOverwrite={handleConflictForceOverwrite}
+ onDismiss={clearConflict}
+ />
+ )}
+
+ {/* Remote update notification */}
+ {remoteUpdate && (
+ <UpdateNotification
+ updatedBy={remoteUpdate.updatedBy}
+ onRefresh={handleRemoteUpdateRefresh}
+ onDismiss={handleRemoteUpdateDismiss}
+ />
+ )}
</div>
);
}