From e088ef6974017a8d76d8bad58b7f17125e249c83 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 11:29:03 +0100 Subject: [PATCH] fix(workspace): dedicated reconnecting websocket, restore editing lock blocking & amber banner, broadcast live doc/folder moves --- .../src/features/workspace/WorkspaceView.tsx | 212 +++++++++--------- frontend/src/lib/api.ts | 7 + internal/api/server.go | 56 +++++ internal/storage/sqlite.go | 132 +++++++++++ internal/storage/sqlite_test.go | 24 ++ 5 files changed, 323 insertions(+), 108 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index e7f2524..0310ca9 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -50,7 +50,7 @@ import { Radio, } from 'lucide-react'; import { BlockType, Document, DocumentBlock, DocBlocksPatchEvent, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types'; -import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected } from '../../lib/api'; +import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected, buildWebSocketURL } from '../../lib/api'; import { CodeBlockItem } from './CodeBlockItem'; import { UserAutocomplete } from '../UserAutocomplete'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; @@ -129,20 +129,10 @@ export const WorkspaceView: React.FC = ({ const [showIconPicker, setShowIconPicker] = useState(false); const [showWikiLinkMenu, setShowWikiLinkMenu] = useState(false); - // WebSocket connection status for live indicator - const [wsConnected, setWsConnected] = useState(() => isWebSocketConnected()); - - useEffect(() => { - const unsub = onWebSocketStatus((connected) => { - setWsConnected(connected); - if (connected) { - // Fresh re-fetch when reconnecting - api.getFolders().then(setCustomFolders).catch(() => {}); - api.getSharedFolders().then(setSharedFolders).catch(() => {}); - } - }); - return unsub; - }, []); + // Dedicated WebSocket connection & status for live indicator + const wsRef = useRef(null); + const wsReconnectTimerRef = useRef(null); + const [wsConnected, setWsConnected] = useState(false); // Folder state (synchronized with server) const [customFolders, setCustomFolders] = useState([]); @@ -237,7 +227,7 @@ export const WorkspaceView: React.FC = ({ const blockInputRefs = useRef>({}); const isLockedByOther = !!(activeLock && activeLock.userId !== currentUser?.id); - const isReadOnly = selectedDoc?.permission === 'read'; + const isReadOnly = selectedDoc?.permission === 'read' || isLockedByOther; // Track server-synced blocks & metadata per document, plus local unsaved block deltas const syncedDocMetaRef = useRef>(new Map()); @@ -820,10 +810,11 @@ export const WorkspaceView: React.FC = ({ }; }, []); - // Real-time WebSocket listener for Document, Block Delta & Folder events + // Dedicated Real-time WebSocket connection for Document, Block Delta, Lock & Folder events useEffect(() => { - const handleWs = (e: any) => { - const msg = e.detail; + let isMounted = true; + + const handleWsMsg = (msg: any) => { if (!msg || !msg.event) return; if (msg.event === 'DOC_LOCKED') { @@ -957,8 +948,80 @@ export const WorkspaceView: React.FC = ({ } }; - window.addEventListener('mindsift:ws', handleWs); - return () => window.removeEventListener('mindsift:ws', handleWs); + const connect = () => { + if (!isMounted) return; + const token = getToken(); + if (!token) { + setWsConnected(false); + return; + } + + if (wsRef.current) { + try { + wsRef.current.close(); + } catch {} + wsRef.current = null; + } + + try { + const url = buildWebSocketURL(); + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + if (!isMounted) return; + setWsConnected(true); + api.getFolders().then(setCustomFolders).catch(() => {}); + api.getSharedFolders().then(setSharedFolders).catch(() => {}); + }; + + ws.onclose = () => { + if (!isMounted) return; + setWsConnected(false); + clearTimeout(wsReconnectTimerRef.current); + wsReconnectTimerRef.current = setTimeout(connect, 2000); + }; + + ws.onerror = () => { + if (!isMounted) return; + setWsConnected(false); + }; + + ws.onmessage = (evt) => { + try { + const msg = JSON.parse(evt.data); + handleWsMsg(msg); + } catch {} + }; + } catch (err) { + if (!isMounted) return; + setWsConnected(false); + clearTimeout(wsReconnectTimerRef.current); + wsReconnectTimerRef.current = setTimeout(connect, 3000); + } + }; + + connect(); + + // Fallback: also listen for window custom event 'mindsift:ws' + const handleGlobalCustomEvent = (e: any) => { + if (e.detail) { + handleWsMsg(e.detail); + } + }; + window.addEventListener('mindsift:ws', handleGlobalCustomEvent); + + return () => { + isMounted = false; + clearTimeout(wsReconnectTimerRef.current); + if (wsRef.current) { + try { + wsRef.current.close(); + } catch {} + wsRef.current = null; + } + window.removeEventListener('mindsift:ws', handleGlobalCustomEvent); + }; }, [onStatsChange]); const openShareModal = async () => { @@ -1278,91 +1341,24 @@ export const WorkspaceView: React.FC = ({ return; } - // Name of the folder being moved - const folderName = cleanSource.split('/').pop() || cleanSource; - const newFolderPath = cleanTarget ? `${cleanTarget}/${folderName}` : folderName; - - if (newFolderPath === cleanSource) return; - triggerHaptic(15); - - // Check if sourcePath is the root mount of a shared folder - const matchingSF = sharedFolders.find((sf) => sf.mountPath === cleanSource); - if (matchingSF) { - try { - await api.updateFolderMount(matchingSF.id, newFolderPath); - const sfList = await api.getSharedFolders(); - setSharedFolders(sfList); - await loadDocs(selectedDoc?.id); - setExpandedFolders((prev) => { - const next = new Set(prev); - next.add(newFolderPath); - if (cleanTarget) next.add(cleanTarget); - return next; - }); - } catch (err: any) { - console.error('Failed to move shared folder mount', err); - alert(err.message || 'Fehler beim Verschieben des geteilten Ordners'); - } - return; - } - - // 1. Update all documents that are in cleanSource or any subfolder of cleanSource - const docsToUpdate = docs.filter((d) => { - const f = getDocFolder(d); - return f === cleanSource || f.startsWith(cleanSource + '/'); - }); - - const updatedDocsList = docs.map((d) => { - const f = getDocFolder(d); - if (f === cleanSource) { - return { ...d, folderPath: newFolderPath }; - } else if (f.startsWith(cleanSource + '/')) { - const suffix = f.slice(cleanSource.length); - return { ...d, folderPath: `${newFolderPath}${suffix}` }; - } - return d; - }); - - setDocs(updatedDocsList); - if (selectedDoc) { - const found = updatedDocsList.find((d) => d.id === selectedDoc.id); - if (found) setSelectedDoc(found); - } - try { - await api.createFolder(newFolderPath); - const subFoldersToRecreate = customFolders.filter((cf) => cf.startsWith(cleanSource + '/')); - for (const sf of subFoldersToRecreate) { - const suffix = sf.slice(cleanSource.length); - await api.createFolder(`${newFolderPath}${suffix}`); - } - // Persist all affected docs to backend BEFORE deleting the old empty folder - await Promise.all( - docsToUpdate.map((d) => { - const f = getDocFolder(d); - let nextFolder = newFolderPath; - if (f.startsWith(cleanSource + '/')) { - const suffix = f.slice(cleanSource.length); - nextFolder = `${newFolderPath}${suffix}`; - } - return api.moveDocument(d.id, nextFolder); - }) - ); - await api.deleteFolder(cleanSource); - const freshFolders = await api.getFolders(); - setCustomFolders(freshFolders); - } catch (err) { - console.error('Error syncing moved folders with server', err); + const res = await api.moveFolder(cleanSource, cleanTarget); + await Promise.all([ + api.getFolders().then(setCustomFolders), + api.getSharedFolders().then(setSharedFolders), + loadDocs(selectedDoc?.id), + ]); + setExpandedFolders((prev) => { + const next = new Set(prev); + if (res.newPath) next.add(res.newPath); + if (cleanTarget) next.add(cleanTarget); + return next; + }); + } catch (err: any) { + console.error('Failed to move folder', err); + alert(err.message || 'Fehler beim Verschieben des Ordners'); } - - // Expand the new folder path and its parent - setExpandedFolders((prev) => { - const next = new Set(prev); - next.add(newFolderPath); - if (cleanTarget) next.add(cleanTarget); - return next; - }); }; const handleDropDocToFolder = async (folderPath: string, e: React.DragEvent) => { @@ -4216,16 +4212,16 @@ export const WorkspaceView: React.FC = ({ {isLockedByOther && ( -
- +
+
- {activeLock?.userName || 'Ein anderer Benutzer'} bearbeitet diese Notiz gerade ebenfalls. -

Live-Zusammenarbeit aktiv – Änderungen werden automatisch Punkt für Punkt synchronisiert.

+ Diese Notiz wird aktuell von {activeLock?.userName || 'einem anderen Benutzer'} bearbeitet. +

Die Bearbeitung ist vorübergehend gesperrt, um Zeilen- und Datenverlust zu vermeiden.

)} - {selectedDoc.permission === 'read' && ( + {!isLockedByOther && selectedDoc.permission === 'read' && (
Du hast Nur-Lesezugriff auf diese Notiz. Änderungen können nicht vorgenommen werden. diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index acf96fe..1288836 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -434,6 +434,13 @@ export const api = { ); }, + async moveFolder(sourcePath: string, targetParentPath: string): Promise<{ newPath: string; movedDocIds: string[] }> { + return request<{ newPath: string; movedDocIds: string[] }>('/api/v1/folders/move', { + method: 'POST', + body: JSON.stringify({ sourcePath, targetParentPath }), + }); + }, + // --- WORKSPACE SHARED FOLDERS --- async getSharedFolders(): Promise { const res = await request('/api/v1/workspace/folders/shared'); diff --git a/internal/api/server.go b/internal/api/server.go index 849865c..012a9a9 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -104,6 +104,7 @@ func (s *Server) Router() http.Handler { // Workspace Documents & Folders r.Get("/api/v1/folders", s.handleGetFolders) r.Post("/api/v1/folders", s.handleCreateFolder) + r.Post("/api/v1/folders/move", s.handleMoveFolder) r.Delete("/api/v1/folders", s.handleDeleteFolder) r.Get("/api/v1/workspace/folders/shared", s.handleGetSharedFolders) @@ -816,6 +817,7 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } + s.wsHub.BroadcastDocEvent(doc.ID, "DOC_CREATED", doc) s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED") writeJSON(w, http.StatusCreated, doc) } @@ -835,6 +837,16 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") + // Check document lock + if lock := s.wsHub.GetDocLock(docID); lock != nil && lock.UserID != userID { + w.WriteHeader(http.StatusLocked) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Dokument wird aktuell bearbeitet von " + lock.UserName, + "currentLock": lock, + }) + return + } + var req struct { Title *string `json:"title"` Icon *string `json:"icon"` @@ -854,6 +866,7 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { return } if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil { + s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc) s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") } writeJSON(w, http.StatusOK, doc) @@ -863,6 +876,16 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") + // Check document lock + if lock := s.wsHub.GetDocLock(docID); lock != nil && lock.UserID != userID { + w.WriteHeader(http.StatusLocked) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Dokument wird aktuell bearbeitet von " + lock.UserName, + "currentLock": lock, + }) + return + } + var req domain.DocBlocksPatchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") @@ -883,6 +906,7 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques DeletedIDs: req.DeletedIDs, BlockOrder: mergedOrder, } + s.wsHub.BroadcastDocEvent(docID, "DOC_BLOCKS_PATCHED", patchEvent) if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil { for _, uid := range users { s.wsHub.BroadcastToUser(uid, "DOC_BLOCKS_PATCHED", patchEvent) @@ -959,6 +983,37 @@ func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleMoveFolder(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + var req struct { + SourcePath string `json:"sourcePath"` + TargetParentPath string `json:"targetParentPath"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Ungültiges JSON") + return + } + newPath, movedDocIDs, err := s.store.MoveFolder(userID, req.SourcePath, req.TargetParentPath) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + for _, id := range movedDocIDs { + if doc, err := s.store.GetDocumentByID(userID, id); err == nil && doc != nil { + s.wsHub.BroadcastDocEvent(id, "DOC_UPDATED", doc) + s.broadcastDocToAuthorizedUsers(id, "DOC_UPDATED") + } + } + s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{ + "sourcePath": req.SourcePath, + "newPath": newPath, + }) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "newPath": newPath, + "movedDocIds": movedDocIDs, + }) +} + // --- WORKSPACE SHARED FOLDERS --- func (s *Server) handleGetSharedFolders(w http.ResponseWriter, r *http.Request) { @@ -1107,6 +1162,7 @@ func (s *Server) handleMoveDocument(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } + s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc) s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", nil) writeJSON(w, http.StatusOK, doc) diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 60b95e9..f06db3b 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -2807,6 +2807,138 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do return doc, nil } +// MoveFolder moves a folder (and all its documents/subfolders) to a new target parent path. +func (s *Store) MoveFolder(userID, sourcePath, targetParentPath string) (string, []string, error) { + cleanSource := filepath.ToSlash(filepath.Clean(strings.TrimSpace(sourcePath))) + if cleanSource == "" || cleanSource == "." || strings.HasPrefix(cleanSource, "/") || strings.Contains(cleanSource, "..") { + return "", nil, fmt.Errorf("ungültiger Quell-Ordnerpfad") + } + cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetParentPath))) + if cleanTarget == "." { + cleanTarget = "" + } + if cleanTarget != "" && (cleanTarget == cleanSource || strings.HasPrefix(cleanTarget, cleanSource+"/")) { + return "", nil, fmt.Errorf("ein Ordner kann nicht in sich selbst verschoben werden") + } + + folderName := filepath.Base(cleanSource) + var newFolderPath string + if cleanTarget == "" { + newFolderPath = folderName + } else { + newFolderPath = cleanTarget + "/" + folderName + } + + if newFolderPath == cleanSource { + return cleanSource, []string{}, nil + } + + // 1. Check if sourcePath is the root mount of a shared folder + mounts, _, err := s.getUserFolderMounts(userID) + if err == nil { + for sfID, mPath := range mounts { + if mPath == cleanSource { + err := s.UpdateFolderMount(userID, sfID, newFolderPath) + if err != nil { + return "", nil, err + } + return newFolderPath, []string{}, nil + } + } + } + + // 2. Move all affected documents + docs, err := s.GetDocuments(userID) + if err != nil { + return "", nil, err + } + + var movedDocIDs []string + for _, doc := range docs { + if doc.FolderPath == cleanSource || strings.HasPrefix(doc.FolderPath, cleanSource+"/") { + var nextDocFolder string + if doc.FolderPath == cleanSource { + nextDocFolder = newFolderPath + } else { + suffix := strings.TrimPrefix(doc.FolderPath, cleanSource) + nextDocFolder = newFolderPath + suffix + } + _, moveErr := s.MoveDocument(userID, doc.ID, nextDocFolder) + if moveErr == nil { + movedDocIDs = append(movedDocIDs, doc.ID) + } + } + } + + // 3. Re-map workspace_folders entries + targetSFID, targetSub, isTargetSF := matchMountPath(newFolderPath, mounts) + sourceSFID, sourceSub, isSourceSF := matchMountPath(cleanSource, mounts) + + wfRows, qErr := s.db.Query(`SELECT id, user_id, shared_folder_id, path, sub_path FROM workspace_folders`) + if qErr == nil { + type wfRecord struct { + id, uid, sfID, path, subPath string + } + var records []wfRecord + for wfRows.Next() { + var r wfRecord + if err := wfRows.Scan(&r.id, &r.uid, &r.sfID, &r.path, &r.subPath); err == nil { + records = append(records, r) + } + } + _ = wfRows.Close() + + for _, r := range records { + if isSourceSF && r.sfID == sourceSFID { + matches := false + var suffix string + if sourceSub == "" { + matches = true + suffix = "/" + r.subPath + } else if r.subPath == sourceSub { + matches = true + suffix = "" + } else if strings.HasPrefix(r.subPath, sourceSub+"/") { + matches = true + suffix = strings.TrimPrefix(r.subPath, sourceSub) + } + if matches { + var newSub string + if targetSub == "" { + newSub = strings.TrimPrefix(folderName+suffix, "/") + } else { + newSub = targetSub + "/" + folderName + suffix + } + if isTargetSF { + _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, targetSFID, newSub, r.id) + } else { + newP := newFolderPath + suffix + _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = '', sub_path = '', path = ? WHERE id = ?`, newP, r.id) + } + } + } else if r.path == cleanSource || strings.HasPrefix(r.path, cleanSource+"/") { + suffix := strings.TrimPrefix(r.path, cleanSource) + newP := newFolderPath + suffix + if isTargetSF { + var newSub string + if targetSub == "" { + newSub = strings.TrimPrefix(folderName+suffix, "/") + } else { + newSub = targetSub + "/" + folderName + suffix + } + _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ?, path = ? WHERE id = ?`, targetSFID, newSub, newP, r.id) + } else { + _, _ = s.db.Exec(`UPDATE workspace_folders SET path = ? WHERE id = ?`, newP, r.id) + } + } + } + } + + _, _ = s.CreateFolder(userID, newFolderPath) + + return newFolderPath, movedDocIDs, nil +} + // syncSharedFolderContents ensures all documents and subfolders under cleanMount owned by ownerID are linked to folderID. func (s *Store) syncSharedFolderContents(ownerID, folderID, cleanMount string) { if ownerID == "" || folderID == "" || cleanMount == "" { diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index e28b60e..07667c0 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -665,5 +665,29 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) { if !foundSubfolderForB { t.Fatalf("expected User B to see empty shared subfolder 'ExterneProjekte/TeamProjekt/Konzepte', got folders: %v", foldersB) } + + // 3. MoveFolder inside a shared folder + newPath, _, err := store.MoveFolder(userA.ID, "TeamProjekt/Konzepte", "TeamProjekt/Archiv") + if err != nil { + t.Fatalf("MoveFolder failed: %v", err) + } + if newPath != "TeamProjekt/Archiv/Konzepte" { + t.Fatalf("expected newPath 'TeamProjekt/Archiv/Konzepte', got %q", newPath) + } + + // User B must now see "ExterneProjekte/TeamProjekt/Archiv/Konzepte" + foldersBAfterMove, err := store.GetFolders(userB.ID) + if err != nil { + t.Fatalf("GetFolders after MoveFolder failed: %v", err) + } + foundMovedForB := false + for _, f := range foldersBAfterMove { + if f == "ExterneProjekte/TeamProjekt/Archiv/Konzepte" { + foundMovedForB = true + } + } + if !foundMovedForB { + t.Fatalf("expected User B to see moved shared subfolder 'ExterneProjekte/TeamProjekt/Archiv/Konzepte', got %v", foldersBAfterMove) + } }