diff options
| author | soryu <soryu@soryu.co> | 2025-12-23 22:20:52 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2025-12-23 22:20:52 +0000 |
| commit | 72c2590571104b8d10e3f72d7a5b984d0b520c51 (patch) | |
| tree | 735aa03056a44a93b9abdf915545ad034ee2b597 /makima/frontend/src/hooks | |
| parent | f5222a7ae5ade5589436778cb01fc0abe625b3c3 (diff) | |
| download | soryu-72c2590571104b8d10e3f72d7a5b984d0b520c51.tar.gz soryu-72c2590571104b8d10e3f72d7a5b984d0b520c51.zip | |
Add conflict notification and file update WS endpoint
Diffstat (limited to 'makima/frontend/src/hooks')
| -rw-r--r-- | makima/frontend/src/hooks/useFileSubscription.ts | 154 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useFiles.ts | 23 |
2 files changed, 177 insertions, 0 deletions
diff --git a/makima/frontend/src/hooks/useFileSubscription.ts b/makima/frontend/src/hooks/useFileSubscription.ts new file mode 100644 index 0000000..7260b96 --- /dev/null +++ b/makima/frontend/src/hooks/useFileSubscription.ts @@ -0,0 +1,154 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import { FILE_SUBSCRIBE_ENDPOINT } from "../lib/api"; + +export interface FileUpdateEvent { + fileId: string; + version: number; + updatedFields: string[]; + updatedBy: "user" | "llm" | "system"; +} + +interface UseFileSubscriptionOptions { + fileId: string | null; + onUpdate?: (event: FileUpdateEvent) => void; + onError?: (error: string) => void; +} + +export function useFileSubscription(options: UseFileSubscriptionOptions) { + const { fileId, onUpdate, onError } = options; + const [connected, setConnected] = useState(false); + const wsRef = useRef<WebSocket | null>(null); + const reconnectTimeoutRef = useRef<number | null>(null); + const subscribedFileRef = useRef<string | null>(null); + + // Store callbacks in refs to avoid re-connecting when callbacks change + const callbacksRef = useRef({ onUpdate, onError }); + useEffect(() => { + callbacksRef.current = { onUpdate, onError }; + }, [onUpdate, onError]); + + const connect = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) return; + + try { + const ws = new WebSocket(FILE_SUBSCRIBE_ENDPOINT); + wsRef.current = ws; + + ws.onopen = () => { + setConnected(true); + // Re-subscribe if we had a subscription + if (subscribedFileRef.current) { + ws.send( + JSON.stringify({ + type: "subscribe", + fileId: subscribedFileRef.current, + }) + ); + } + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + + if (message.type === "fileUpdated") { + callbacksRef.current.onUpdate?.({ + fileId: message.fileId, + version: message.version, + updatedFields: message.updatedFields, + updatedBy: message.updatedBy, + }); + } else if (message.type === "error") { + callbacksRef.current.onError?.(message.message); + } + } catch (e) { + console.error("Failed to parse file subscription message:", e); + } + }; + + ws.onerror = () => { + callbacksRef.current.onError?.("WebSocket connection error"); + }; + + ws.onclose = () => { + setConnected(false); + wsRef.current = null; + + // Attempt reconnection after 3 seconds if we still have a subscription + if (subscribedFileRef.current) { + reconnectTimeoutRef.current = window.setTimeout(() => { + connect(); + }, 3000); + } + }; + } catch (e) { + callbacksRef.current.onError?.( + e instanceof Error ? e.message : "Failed to connect" + ); + } + }, []); + + const subscribe = useCallback( + (id: string) => { + subscribedFileRef.current = id; + + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ + type: "subscribe", + fileId: id, + }) + ); + } else { + connect(); + } + }, + [connect] + ); + + const unsubscribe = useCallback(() => { + if ( + subscribedFileRef.current && + wsRef.current?.readyState === WebSocket.OPEN + ) { + wsRef.current.send( + JSON.stringify({ + type: "unsubscribe", + fileId: subscribedFileRef.current, + }) + ); + } + subscribedFileRef.current = null; + }, []); + + // Auto-subscribe when fileId changes + useEffect(() => { + if (fileId) { + subscribe(fileId); + } else { + unsubscribe(); + } + + return () => { + unsubscribe(); + }; + }, [fileId, subscribe, unsubscribe]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (wsRef.current) { + wsRef.current.close(); + } + }; + }, []); + + return { + connected, + subscribe, + unsubscribe, + }; +} diff --git a/makima/frontend/src/hooks/useFiles.ts b/makima/frontend/src/hooks/useFiles.ts index aacbb6a..1998357 100644 --- a/makima/frontend/src/hooks/useFiles.ts +++ b/makima/frontend/src/hooks/useFiles.ts @@ -5,16 +5,24 @@ import { createFile, updateFile, deleteFile, + VersionConflictError, type FileSummary, type FileDetail, type CreateFileRequest, type UpdateFileRequest, } from "../lib/api"; +export interface ConflictState { + hasConflict: boolean; + expectedVersion: number; + actualVersion: number; +} + export function useFiles() { const [files, setFiles] = useState<FileSummary[]>([]); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); + const [conflict, setConflict] = useState<ConflictState | null>(null); const fetchFiles = useCallback(async () => { setLoading(true); @@ -60,11 +68,20 @@ export function useFiles() { const editFile = useCallback( async (id: string, data: UpdateFileRequest): Promise<FileDetail | null> => { setError(null); + setConflict(null); try { const file = await updateFile(id, data); await fetchFiles(); // Refresh list return file; } catch (e) { + if (e instanceof VersionConflictError) { + setConflict({ + hasConflict: true, + expectedVersion: e.expectedVersion, + actualVersion: e.actualVersion, + }); + return null; + } setError(e instanceof Error ? e.message : "Failed to update file"); return null; } @@ -72,6 +89,10 @@ export function useFiles() { [fetchFiles] ); + const clearConflict = useCallback(() => { + setConflict(null); + }, []); + const removeFile = useCallback( async (id: string): Promise<boolean> => { setError(null); @@ -96,6 +117,8 @@ export function useFiles() { files, loading, error, + conflict, + clearConflict, fetchFiles, fetchFile, saveFile, |
