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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
This commit is contained in:
1 parent
ed673b0707
commit
e088ef6974
5 files changed
+323
-108
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user