fix(workspace): dedicated reconnecting websocket, restore editing lock blocking & amber banner, broadcast live doc/folder moves
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s

This commit is contained in:
sandrodach committed 2026-10-26 11:29:03 +01:00
1 parent ed673b0707
commit e088ef6974
5 files changed
+323 -108

No files matched your search

+104 -108
View File
@@ -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<WorkspaceViewProps> = ({
const [showIconPicker, setShowIconPicker] = useState(false);
const [showWikiLinkMenu, setShowWikiLinkMenu] = useState(false);
// WebSocket connection status for live indicator
const [wsConnected, setWsConnected] = useState<boolean>(() => 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<WebSocket | null>(null);
const wsReconnectTimerRef = useRef<any>(null);
const [wsConnected, setWsConnected] = useState<boolean>(false);
// Folder state (synchronized with server)
const [customFolders, setCustomFolders] = useState<string[]>([]);
@@ -237,7 +227,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const blockInputRefs = useRef<Record<string, HTMLTextAreaElement | HTMLInputElement | null>>({});
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<Map<string, { title: string; icon: string; tagsJson: string; isPinned: boolean }>>(new Map());
@@ -820,10 +810,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
};
}, []);
// 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<WorkspaceViewProps> = ({
}
};
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<WorkspaceViewProps> = ({
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<WorkspaceViewProps> = ({
{isLockedByOther && (
<div className="p-2.5 bg-emerald-500/10 border border-emerald-500/30 text-emerald-700 dark:text-emerald-300 rounded-card flex items-center gap-2.5 text-xs font-medium">
<Radio className="w-4 h-4 text-emerald-500 animate-pulse flex-shrink-0" />
<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300 rounded-card flex items-center gap-2.5 text-xs font-medium animate-pulse">
<Lock className="w-4 h-4 text-amber-500 flex-shrink-0" />
<div>
<span><strong>{activeLock?.userName || 'Ein anderer Benutzer'}</strong> bearbeitet diese Notiz gerade ebenfalls.</span>
<p className="text-[11px] opacity-80 mt-0.5">Live-Zusammenarbeit aktiv – Änderungen werden automatisch Punkt für Punkt synchronisiert.</p>
<span>Diese Notiz wird aktuell von <strong>{activeLock?.userName || 'einem anderen Benutzer'}</strong> bearbeitet.</span>
<p className="text-[11px] opacity-80 mt-0.5">Die Bearbeitung ist vorübergehend gesperrt, um Zeilen- und Datenverlust zu vermeiden.</p>
</div>
</div>
)}
{selectedDoc.permission === 'read' && (
{!isLockedByOther && selectedDoc.permission === 'read' && (
<div className="p-2.5 bg-blue-500/10 border border-blue-500/30 text-blue-700 dark:text-blue-300 rounded-card flex items-center gap-2 text-xs">
<Eye className="w-4 h-4 text-blue-500 flex-shrink-0" />
<span>Du hast <strong>Nur-Lesezugriff</strong> auf diese Notiz. Änderungen können nicht vorgenommen werden.</span>
+7
View File
@@ -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<SharedFolder[]> {
const res = await request<SharedFolder[]>('/api/v1/workspace/folders/shared');