fix(workspace): streamline write lock release, sync, and active editor badge
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s

This commit is contained in:
sandrodach committed 2026-10-25 11:21:46 +01:00
1 parent 51244c736c
commit 920716be25
3 files changed
+132 -73

No files matched your search

+123 -66
View File
@@ -47,7 +47,7 @@ import {
Share2,
} from 'lucide-react';
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { api, getToken, triggerHaptic } from '../../lib/api';
interface WorkspaceViewProps {
currentUser?: User | null;
@@ -155,6 +155,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const heartbeatTimerRef = useRef<any>(null);
const idleUnlockTimerRef = useRef<any>(null);
const currentDocIdRef = useRef<string | null>(null);
const isEditorFocusedRef = useRef(false);
const blockInputRefs = useRef<Record<string, HTMLTextAreaElement | HTMLInputElement | null>>({});
@@ -181,12 +182,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setHoldsLock(false);
stopHeartbeat();
}
}, 10000);
}, 4000);
};
const releaseMyLock = async (docId?: string) => {
const targetDocId = docId || currentDocIdRef.current;
if (!targetDocId || !holdsLockRef.current) return;
if (!targetDocId) return;
const wasHolding = holdsLockRef.current;
stopHeartbeat();
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
@@ -194,24 +196,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
holdsLockRef.current = false;
setHoldsLock(false);
try {
await api.unlockDocument(targetDocId);
} catch {}
};
const resetIdleTimer = () => {
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
if (wasHolding || docId) {
try {
await api.unlockDocument(targetDocId);
} catch {}
}
idleUnlockTimerRef.current = setTimeout(() => {
releaseMyLock();
}, 25000);
};
const ensureLock = async (): Promise<boolean> => {
if (!selectedDoc || selectedDoc.permission === 'read') return false;
if (holdsLockRef.current) {
resetIdleTimer();
return true;
}
try {
@@ -221,7 +215,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setHoldsLock(true);
setActiveLock(null);
startHeartbeat(selectedDoc.id);
resetIdleTimer();
return true;
} else if (res.lock) {
setActiveLock(res.lock);
@@ -235,6 +228,32 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return false;
};
const handleBlockFocus = () => {
isEditorFocusedRef.current = true;
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
idleUnlockTimerRef.current = null;
}
if (!isReadOnly) {
ensureLock();
}
};
const handleBlockBlur = () => {
isEditorFocusedRef.current = false;
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
}
// Short grace period: if user clicked into another block in the doc, focus will cancel this.
// If user clicked outside or stopped editing, save and release the lock immediately!
idleUnlockTimerRef.current = setTimeout(async () => {
if (!isEditorFocusedRef.current && holdsLockRef.current) {
await flushPendingSave();
await releaseMyLock();
}
}, 1500);
};
// Debounced auto-save refs (500ms delay to avoid writing every keystroke to SSD)
const debounceSaveTimerRef = useRef<any>(null);
const pendingSaveDocRef = useRef<Document | null>(null);
@@ -274,6 +293,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
pendingSaveDocRef.current = null;
setSaveState('saved');
}
if (!isEditorFocusedRef.current && holdsLockRef.current) {
releaseMyLock();
}
}
};
@@ -342,6 +364,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
loadDocs(initialDocId);
}, [initialDocId]);
const selectedDocIdRef = useRef<string | null>(null);
selectedDocIdRef.current = selectedDoc?.id || null;
const currentUserIdRef = useRef<string | null>(null);
currentUserIdRef.current = currentUser?.id || null;
// Handle document switch and lock status checks
useEffect(() => {
const prevId = currentDocIdRef.current;
@@ -374,13 +401,36 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
};
}, [selectedDoc?.id]);
// Window blur / visibility change listener: release lock when user leaves tab/window
useEffect(() => {
const handleWindowBlur = () => {
if (!isEditorFocusedRef.current && holdsLockRef.current) {
flushPendingSave();
releaseMyLock();
}
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
isEditorFocusedRef.current = false;
flushPendingSave();
releaseMyLock();
}
};
window.addEventListener('blur', handleWindowBlur);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('blur', handleWindowBlur);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
// Flush pending save and release lock before tab closes or unmounts
useEffect(() => {
const handleBeforeUnload = () => {
flushPendingSave();
if (currentDocIdRef.current && holdsLockRef.current) {
try {
const token = localStorage.getItem('mindsift_token') || sessionStorage.getItem('mindsift_token');
const token = getToken();
fetch(`/api/v1/documents/${currentDocIdRef.current}/lock`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {},
@@ -407,16 +457,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (msg.event === 'DOC_LOCKED') {
const payload = msg.payload as DocLockInfo;
if (payload && payload.docId === selectedDoc?.id) {
if (payload.userId !== currentUser?.id) {
if (payload && payload.docId === selectedDocIdRef.current) {
if (payload.userId !== currentUserIdRef.current) {
setActiveLock(payload);
}
}
} else if (msg.event === 'DOC_UNLOCKED') {
const payload = msg.payload as { docId?: string; doc_id?: string; id?: string };
const unlockedDocId = payload?.docId || payload?.doc_id || payload?.id;
if (unlockedDocId && unlockedDocId === selectedDoc?.id) {
if (unlockedDocId && unlockedDocId === selectedDocIdRef.current) {
setActiveLock(null);
// Fetch fresh document from backend so updates made by the other user are immediately visible
api.getDocument(unlockedDocId).then((freshDoc) => {
if (freshDoc && freshDoc.id === selectedDocIdRef.current) {
setSelectedDoc((prev) => (prev && prev.id === freshDoc.id ? { ...prev, ...freshDoc, isPinned: prev.isPinned } : prev));
setDocs((prev) => prev.map((d) => (d.id === freshDoc.id ? { ...d, ...freshDoc, isPinned: d.isPinned } : d)));
}
}).catch(() => {});
}
} else if (msg.event === 'DOC_CREATED') {
const created = msg.payload as Document;
@@ -430,7 +487,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setDocs((prev) =>
prev.map((d) => (d.id === updated.id ? { ...d, ...updated, isPinned: d.isPinned } : d))
);
if (selectedDoc?.id === updated.id) {
if (selectedDocIdRef.current === updated.id) {
if (!holdsLockRef.current) {
setSelectedDoc((prev) => (prev ? { ...prev, ...updated, isPinned: prev.isPinned } : updated));
}
@@ -441,7 +498,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id;
if (deletedId) {
setDocs((prev) => prev.filter((d) => d.id !== deletedId));
if (selectedDoc?.id === deletedId) {
if (selectedDocIdRef.current === deletedId) {
setSelectedDoc(null);
}
onStatsChange();
@@ -451,7 +508,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
window.addEventListener('mindsift:ws', handleWs);
return () => window.removeEventListener('mindsift:ws', handleWs);
}, [selectedDoc?.id, currentUser?.id, onStatsChange]);
}, [onStatsChange]);
const openShareModal = async () => {
if (!selectedDoc) return;
@@ -1904,14 +1961,32 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{selectedDoc.isOwner === false && (
<span
className={`px-2 py-0.5 rounded text-[10px] font-semibold border flex items-center gap-1 ${
selectedDoc.permission === 'read'
? 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
}`}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle"
title={`Geteilt von ${selectedDoc.ownerName || 'Eigentümer'}`}
>
{selectedDoc.permission === 'read' ? 'Nur Lesezugriff' : 'Schreibzugriff'}
{selectedDoc.ownerName ? ` (von ${selectedDoc.ownerName})` : ' (Geteilt)'}
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {selectedDoc.ownerName || 'Eigentümer'}</span>
</span>
)}
{selectedDoc.permission === 'read' && (
<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">
<Lock className="w-2.5 h-2.5" />
<span>Nur Lesezugriff</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>
)}
{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">
<Lock className="w-2.5 h-2.5" />
<span>Wird bearbeitet ({activeLock?.userName || 'anderer Nutzer'})</span>
</span>
)}
@@ -2064,10 +2139,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
type="text"
readOnly={isReadOnly}
value={selectedDoc.title}
onFocus={() => {
if (!isReadOnly) ensureLock();
}}
onBlur={() => resetIdleTimer()}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => {
if (!isReadOnly) {
ensureLock();
@@ -2218,10 +2291,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
type="text"
readOnly={isReadOnly}
value={block.content}
onFocus={() => {
if (!isReadOnly) ensureLock();
}}
onBlur={() => resetIdleTimer()}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
className={`w-full bg-transparent text-sm focus:outline-none ${
@@ -2281,10 +2352,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
type="text"
readOnly={isReadOnly}
value={block.content}
onFocus={() => {
if (!isReadOnly) ensureLock();
}}
onBlur={() => resetIdleTimer()}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
className={`w-full bg-transparent text-lg font-bold text-main focus:outline-none ${
@@ -2300,10 +2369,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
type="text"
readOnly={isReadOnly}
value={block.content}
onFocus={() => {
if (!isReadOnly) ensureLock();
}}
onBlur={() => resetIdleTimer()}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
className={`w-full bg-transparent text-base font-semibold text-accent focus:outline-none ${
@@ -2319,10 +2386,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
type="text"
readOnly={isReadOnly}
value={block.content}
onFocus={() => {
if (!isReadOnly) ensureLock();
}}
onBlur={() => resetIdleTimer()}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
className={`w-full bg-transparent text-sm font-semibold text-main focus:outline-none ${
@@ -2340,10 +2405,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
readOnly={isReadOnly}
rows={Math.max(1, Math.ceil((block.content || '').length / 70))}
value={block.content}
onFocus={() => {
if (!isReadOnly) ensureLock();
}}
onBlur={() => resetIdleTimer()}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
className={`w-full bg-transparent text-xs text-main focus:outline-none resize-none ${
@@ -2361,10 +2424,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
readOnly={isReadOnly}
rows={Math.max(2, (block.content || '').split('\n').length)}
value={block.content}
onFocus={() => {
if (!isReadOnly) ensureLock();
}}
onBlur={() => resetIdleTimer()}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
className={`w-full bg-transparent text-xs font-mono text-main focus:outline-none resize-none ${
@@ -2385,10 +2446,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
readOnly={isReadOnly}
rows={Math.max(3, (block.content || '').split('\n').length)}
value={block.content}
onFocus={() => {
if (!isReadOnly) ensureLock();
}}
onBlur={() => resetIdleTimer()}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
className={`w-full bg-transparent font-mono text-xs text-main focus:outline-none resize-none ${
@@ -2444,10 +2503,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
readOnly={isReadOnly}
rows={Math.max(1, Math.ceil((block.content || '').length / 80))}
value={block.content}
onFocus={() => {
if (!isReadOnly) ensureLock();
}}
onBlur={() => resetIdleTimer()}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
className={`w-full bg-transparent text-sm text-main leading-relaxed focus:outline-none resize-none placeholder:text-faint ${
+5 -3
View File
@@ -899,7 +899,7 @@ func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
userName = user.DisplayName
}
info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 30*time.Second)
info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 12*time.Second)
if !acquired {
w.WriteHeader(http.StatusLocked)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -922,14 +922,16 @@ func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleHeartbeatDocLock(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
ok := s.wsHub.HeartbeatDocLock(docID, userID, 30*time.Second)
ok := s.wsHub.HeartbeatDocLock(docID, userID, 12*time.Second)
writeJSON(w, http.StatusOK, map[string]bool{"extended": ok})
}
func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
released := s.wsHub.ReleaseDocLock(docID, userID)
user, _ := s.store.GetUserByID(userID)
isAdmin := user != nil && user.Role == "admin"
released := s.wsHub.ReleaseDocLock(docID, userID, isAdmin)
if released {
s.wsHub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
}
+4 -4
View File
@@ -185,7 +185,7 @@ func (h *Hub) HeartbeatDocLock(docID, userID string, ttl time.Duration) bool {
return true
}
func (h *Hub) ReleaseDocLock(docID, userID string) bool {
func (h *Hub) ReleaseDocLock(docID, userID string, isAdmin bool) bool {
h.mu.Lock()
defer h.mu.Unlock()
@@ -193,7 +193,7 @@ func (h *Hub) ReleaseDocLock(docID, userID string) bool {
if !ok {
return false
}
if item.userID == userID || userID == "admin" {
if item.userID == userID || isAdmin || userID == "admin" {
delete(h.locks, docID)
return true
}
@@ -217,7 +217,7 @@ func (h *Hub) GetDocLock(docID string) *domain.DocLockInfo {
}
func (h *Hub) cleanupLocksLoop() {
ticker := time.NewTicker(4 * time.Second)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for range ticker.C {
@@ -387,7 +387,7 @@ func (c *Client) readPump() {
case "HEARTBEAT_DOC":
c.hub.HeartbeatDocLock(docID, c.userID, 30*time.Second)
case "UNLOCK_DOC":
if c.hub.ReleaseDocLock(docID, c.userID) {
if c.hub.ReleaseDocLock(docID, c.userID, false) {
c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
}
}