feat(workspace): implement Yjs CRDT real-time collaborative editing (resolves FLAW-01)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 29s

This commit is contained in:
sandrodach committed 2026-10-26 15:18:48 +01:00
1 parent 0bdeb5f320
commit ef8cf8f022
13 files changed
+1028 -85

No files matched your search

+175 -19
View File
@@ -55,6 +55,7 @@ import { CodeBlockItem } from './CodeBlockItem';
import { UserAutocomplete } from '../UserAutocomplete';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentCRDTSession, CollaboratorAwareness, getCollaboratorColor } from '../../lib/crdt';
interface WorkspaceViewProps {
currentUser?: User | null;
@@ -216,7 +217,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [showFolderShareModal, setShowFolderShareModal] = useState(false);
const [folderShares, setFolderShares] = useState<FolderShare[]>([]);
// --- DOCUMENT LOCKING STATE ---
// --- DOCUMENT CRDT & COLLABORATION STATE ---
const crdtSessionRef = useRef<DocumentCRDTSession | null>(null);
const [activePeers, setActivePeers] = useState<CollaboratorAwareness[]>([]);
// --- DOCUMENT LOCKING STATE (FALLBACK) ---
const [activeLock, setActiveLock] = useState<DocLockInfo | null>(null);
const [holdsLock, setHoldsLock] = useState(false);
const holdsLockRef = useRef(false);
@@ -228,8 +233,9 @@ 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' || isLockedByOther;
// With CRDT, multiple users can write simultaneously without lockouts
const isLockedByOther = false;
const isReadOnly = selectedDoc?.permission === 'read';
// 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());
@@ -418,12 +424,34 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return false;
};
const handleBlockFocus = () => {
const broadcastAwareness = (blockId: string | null) => {
if (!selectedDoc || !wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
try {
wsRef.current.send(
JSON.stringify({
event: 'CRDT_AWARENESS',
docId: selectedDoc.id,
payload: {
userId: currentUser?.id,
userName: currentUser?.displayName || currentUser?.username || 'Kollege',
activeBlockId: blockId,
color: getCollaboratorColor(currentUser?.id || ''),
lastActive: Date.now(),
},
})
);
} catch {}
};
const handleBlockFocus = (blockId?: string) => {
isEditorFocusedRef.current = true;
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
idleUnlockTimerRef.current = null;
}
if (blockId) {
broadcastAwareness(blockId);
}
if (!isReadOnly) {
ensureLock();
}
@@ -431,6 +459,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const handleBlockBlur = () => {
isEditorFocusedRef.current = false;
broadcastAwareness(null);
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
}
@@ -611,6 +640,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
});
// Sync into active CRDT session
if (crdtSessionRef.current && crdtSessionRef.current.docId === updatedDoc.id) {
crdtSessionRef.current.syncFullBlocks(updatedDoc.blocks || []);
}
// 1. Update local UI state immediately for responsive, instant typing
setSelectedDoc(updatedDoc);
setDocs((prev) => prev.map((d) => (d.id === updatedDoc.id ? updatedDoc : d)));
@@ -732,16 +766,73 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const currentUserIdRef = useRef<string | null>(null);
currentUserIdRef.current = currentUser?.id || null;
// Handle document switch and lock status checks
// Handle document switch, CRDT session lifecycle, and lock status checks
useEffect(() => {
const prevId = currentDocIdRef.current;
if (prevId && prevId !== selectedDoc?.id) {
flushPendingSave();
releaseMyLock(prevId);
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(JSON.stringify({ event: 'CRDT_LEAVE', docId: prevId }));
} catch {}
}
if (crdtSessionRef.current) {
crdtSessionRef.current.destroy();
crdtSessionRef.current = null;
}
setActivePeers([]);
}
currentDocIdRef.current = selectedDoc?.id || null;
if (selectedDoc) {
const targetDoc = selectedDoc;
const session = new DocumentCRDTSession(targetDoc.id);
session.initFromBlocks(targetDoc.blocks || []);
session.onLocalUpdate = (b64Update) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(
JSON.stringify({
event: 'CRDT_UPDATE',
docId: targetDoc.id,
payload: { update: b64Update },
})
);
} catch {}
}
};
session.onRemoteChange = (crdtBlocks) => {
setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks: crdtBlocks } : prev));
setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks: crdtBlocks } : d)));
};
crdtSessionRef.current = session;
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(JSON.stringify({ event: 'CRDT_JOIN', docId: targetDoc.id }));
} catch {}
}
api
.getDocumentCRDT(targetDoc.id)
.then((res) => {
if (res && crdtSessionRef.current && crdtSessionRef.current.docId === targetDoc.id) {
if (res.state) {
crdtSessionRef.current.applyRemoteUpdate(res.state);
}
if (res.updates && Array.isArray(res.updates)) {
crdtSessionRef.current.applyRemoteUpdates(res.updates);
}
const blocks = crdtSessionRef.current.getBlocks();
if (blocks.length > 0) {
setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks } : prev));
setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks } : d)));
}
}
})
.catch(() => {});
api
.getDocLock(selectedDoc.id)
.then((res) => {
@@ -759,6 +850,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return () => {
flushPendingSave();
if (currentDocIdRef.current) {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(JSON.stringify({ event: 'CRDT_LEAVE', docId: currentDocIdRef.current }));
} catch {}
}
if (crdtSessionRef.current) {
crdtSessionRef.current.destroy();
crdtSessionRef.current = null;
}
releaseMyLock(currentDocIdRef.current);
}
};
@@ -819,7 +919,47 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const handleWsMsg = (msg: any) => {
if (!msg || !msg.event) return;
if (msg.event === 'DOC_LOCKED') {
if (msg.event === 'CRDT_INIT') {
const payload = msg.payload as { docId: string; state?: string; updates?: string[] };
if (payload && payload.docId === selectedDocIdRef.current && crdtSessionRef.current) {
if (payload.state) {
crdtSessionRef.current.applyRemoteUpdate(payload.state);
}
if (payload.updates && Array.isArray(payload.updates)) {
crdtSessionRef.current.applyRemoteUpdates(payload.updates);
}
const blocks = crdtSessionRef.current.getBlocks();
if (blocks.length > 0) {
setSelectedDoc((prev) => (prev && prev.id === payload.docId ? { ...prev, blocks } : prev));
setDocs((prev) => prev.map((d) => (d.id === payload.docId ? { ...d, blocks } : d)));
}
}
} else if (msg.event === 'CRDT_UPDATE') {
const payload = msg.payload as { docId: string; update: string; fromUserId?: string };
if (payload && payload.docId === selectedDocIdRef.current && crdtSessionRef.current) {
if (payload.fromUserId !== currentUserIdRef.current) {
crdtSessionRef.current.applyRemoteUpdate(payload.update);
const blocks = crdtSessionRef.current.getBlocks();
if (blocks && blocks.length > 0) {
setSelectedDoc((prev) => (prev && prev.id === payload.docId ? { ...prev, blocks } : prev));
setDocs((prev) => prev.map((d) => (d.id === payload.docId ? { ...d, blocks } : d)));
}
}
}
} else if (msg.event === 'CRDT_AWARENESS') {
const payload = (msg.payload?.payload || msg.payload) as CollaboratorAwareness;
if (payload && payload.userId && payload.userId !== currentUserIdRef.current) {
setActivePeers((prev) => {
const filtered = prev.filter((c) => c.userId !== payload.userId);
return [...filtered, { ...payload, color: getCollaboratorColor(payload.userId), lastActive: Date.now() }];
});
}
} else if (msg.event === 'CRDT_AWARENESS_LEAVE') {
const payload = msg.payload as { docId?: string; userId?: string };
if (payload && payload.userId) {
setActivePeers((prev) => prev.filter((c) => c.userId !== payload.userId));
}
} else if (msg.event === 'DOC_LOCKED') {
const payload = msg.payload as DocLockInfo;
if (payload && payload.docId === selectedDocIdRef.current) {
if (payload.userId !== currentUserIdRef.current) {
@@ -975,6 +1115,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
wsReconnectDelayRef.current = 2000;
setWsDisconnectReason(null);
setWsConnected(true);
if (selectedDocIdRef.current) {
try {
ws.send(JSON.stringify({ event: 'CRDT_JOIN', docId: selectedDocIdRef.current }));
} catch {}
}
api.getFolders().then(setCustomFolders).catch(() => {});
api.getSharedFolders().then(setSharedFolders).catch(() => {});
};
@@ -4215,22 +4360,21 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</button>
)}
{holdsLock && (
{activePeers.length > 0 ? (
<span
className="px-2 py-0.5 rounded text-[10px] font-semibold border border-emerald-500/30 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center gap-1.5 animate-pulse"
title={`Live-Kollaboration via CRDT aktiv: ${activePeers.map((c) => c.userName).join(', ')}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping" />
<Users className="w-2.5 h-2.5" />
<span>Live ({activePeers.length})</span>
</span>
) : holdsLock ? (
<span className="px-2 py-0.5 rounded text-[10px] font-semibold border border-emerald-500/30 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
<span>Schreibzugriff aktiv</span>
<span>CRDT Sync aktiv</span>
</span>
)}
{isLockedByOther && (
<span
className="px-2 py-0.5 rounded text-[10px] font-semibold border border-amber-500/30 bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center gap-1 animate-pulse cursor-help"
title={`Diese Notiz wird aktuell von ${activeLock?.userName || 'einem anderen Benutzer'} bearbeitet. Schreibzugriff vorübergehend gesperrt.`}
>
<Lock className="w-2.5 h-2.5" />
<span>Wird bearbeitet ({activeLock?.userName || 'anderer Nutzer'})</span>
</span>
)}
) : null}
{selectedDoc.sharedWith && selectedDoc.sharedWith.length > 0 && selectedDoc.isOwner !== false && (
<button
@@ -4590,6 +4734,18 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* Block Content Container */}
<div className="flex-1 min-w-0 pt-0.5 relative">
{/* Live Collaborator Presence Indicator */}
{activePeers.filter((c) => c.activeBlockId === block.id).map((c) => (
<div
key={c.userId}
className="absolute -top-3.5 right-1 text-[10px] px-1.5 py-0.5 rounded font-semibold text-white shadow-xs pointer-events-none z-10 flex items-center gap-1 animate-fadeIn"
style={{ backgroundColor: c.color }}
>
<span className="w-1.5 h-1.5 rounded-full bg-white animate-ping" />
<span>{c.userName}</span>
</div>
))}
{/* TYPE: TODO */}
{block.type === 'todo' && (
<div className="space-y-1">