diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 024a247..0c6654c 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -47,9 +47,10 @@ import { Edit3, Hash, User as UserIcon, + Radio, } from 'lucide-react'; import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types'; -import { api, getToken, triggerHaptic } from '../../lib/api'; +import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected } from '../../lib/api'; import { CodeBlockItem } from './CodeBlockItem'; import { UserAutocomplete } from '../UserAutocomplete'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; @@ -128,6 +129,21 @@ 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; + }, []); + // Folder state (synchronized with server) const [customFolders, setCustomFolders] = useState([]); useEffect(() => { @@ -266,6 +282,7 @@ export const WorkspaceView: React.FC = ({ const ensureLock = async (force = false): Promise => { if (!selectedDoc || selectedDoc.permission === 'read') return false; + if (isLockedByOther) return false; if (holdsLockRef.current && !force) { return true; } @@ -295,7 +312,7 @@ export const WorkspaceView: React.FC = ({ clearTimeout(idleUnlockTimerRef.current); idleUnlockTimerRef.current = null; } - if (!isReadOnly) { + if (!isReadOnly && !isLockedByOther) { ensureLock(); } }; @@ -309,14 +326,13 @@ export const WorkspaceView: React.FC = ({ if (isDraggingBlockRef.current) { return; } - // 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! + // Generous grace period: if user clicked into another block in the doc or paused typing idleUnlockTimerRef.current = setTimeout(async () => { if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { await flushPendingSave(); await releaseMyLock(); } - }, 1500); + }, 8000); }; // Debounced auto-save refs (500ms delay to avoid writing every keystroke to SSD) @@ -325,6 +341,7 @@ export const WorkspaceView: React.FC = ({ const executeSaveToBackend = async (docToSave: Document) => { if (isReadOnly) return; + let saveSucceeded = false; try { const saved = await api.updateDocument(docToSave.id, { title: docToSave.title, @@ -334,6 +351,7 @@ export const WorkspaceView: React.FC = ({ tags: docToSave.tags, isPinned: docToSave.isPinned, }); + saveSucceeded = true; setSelectedDoc((prev) => prev && prev.id === saved.id ? { @@ -352,15 +370,21 @@ export const WorkspaceView: React.FC = ({ api.getTagDetails(selectedTag).then(setTagDetails).catch(() => {}); } } catch (e: any) { + console.error('Failed to save document to backend', e); if (e.message && e.message.includes('423')) { api.getDocLock(docToSave.id).then((res) => { if (res.lock) setActiveLock(res.lock); - }); + }).catch(() => {}); } } finally { - if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) { - pendingSaveDocRef.current = null; - setSaveState('saved'); + if (saveSucceeded) { + if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) { + pendingSaveDocRef.current = null; + setSaveState('saved'); + } + } else { + // Keep pendingSaveDocRef so changes are preserved and not silently marked as saved! + setSaveState('saving'); } if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { if (idleUnlockTimerRef.current) { @@ -370,7 +394,7 @@ export const WorkspaceView: React.FC = ({ if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { await releaseMyLock(); } - }, 2000); + }, 8000); } } }; @@ -3677,6 +3701,52 @@ export const WorkspaceView: React.FC = ({ )} +
+ +
+ + + + {/* Mobile Compact Header Bar (Live-Sync Status, Title, Drawer Switcher) */} +
+
+ +
+
+ {selectedDoc && ( + + )} +
+ +
@@ -3845,6 +3915,16 @@ export const WorkspaceView: React.FC = ({ + {isLockedByOther && ( +
+ +
+ Diese Notiz wird aktuell von {activeLock?.userName || 'einem anderen Benutzer'} bearbeitet. +

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

+
+
+ )} + {!isLockedByOther && selectedDoc.permission === 'read' && (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4421c01..1c6fef5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1019,6 +1019,8 @@ export function getOfflineQueue(): Thought[] { let activeGlobalWS: WebSocket | null = null; let wsReconnectTimer: any = null; let wsListeners: ((msg: WSMessage) => void)[] = []; +let wsStatusListeners: ((connected: boolean) => void)[] = []; +let isWSConnected = false; let manualDisconnect = false; export function buildWebSocketURL(listId?: string): string { @@ -1030,6 +1032,30 @@ export function buildWebSocketURL(listId?: string): string { return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`; } +export function isWebSocketConnected(): boolean { + return isWSConnected && activeGlobalWS !== null && activeGlobalWS.readyState === WebSocket.OPEN; +} + +export function onWebSocketStatus(callback: (connected: boolean) => void): () => void { + wsStatusListeners.push(callback); + callback(isWebSocketConnected()); + return () => { + wsStatusListeners = wsStatusListeners.filter((fn) => fn !== callback); + }; +} + +function notifyStatusChange(connected: boolean) { + isWSConnected = connected; + wsStatusListeners.forEach((fn) => { + try { + fn(connected); + } catch (e) { + console.error('WS status listener error', e); + } + }); + window.dispatchEvent(new CustomEvent('mindsift:ws:status', { detail: { connected } })); +} + export function connectWebSocket(): void { const token = getToken(); if (!token) return; @@ -1044,6 +1070,10 @@ export function connectWebSocket(): void { const ws = new WebSocket(url); activeGlobalWS = ws; + ws.onopen = () => { + notifyStatusChange(true); + }; + ws.onmessage = (event) => { try { const msg = JSON.parse(event.data) as WSMessage; @@ -1062,6 +1092,7 @@ export function connectWebSocket(): void { ws.onclose = () => { activeGlobalWS = null; + notifyStatusChange(false); if (!manualDisconnect && getToken()) { clearTimeout(wsReconnectTimer); wsReconnectTimer = setTimeout(() => { @@ -1071,15 +1102,31 @@ export function connectWebSocket(): void { }; ws.onerror = () => { + notifyStatusChange(false); try { ws.close(); } catch {} }; } catch (e) { console.error('Failed to connect WebSocket', e); + notifyStatusChange(false); } } +// Ensure reconnect when browser recovers from sleep or tab visibility +if (typeof window !== 'undefined') { + window.addEventListener('online', () => { + if (getToken() && !isWebSocketConnected()) { + connectWebSocket(); + } + }); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && getToken() && !isWebSocketConnected()) { + connectWebSocket(); + } + }); +} + export function disconnectWebSocket(): void { manualDisconnect = true; clearTimeout(wsReconnectTimer); @@ -1089,6 +1136,7 @@ export function disconnectWebSocket(): void { } catch {} activeGlobalWS = null; } + notifyStatusChange(false); } export function sendWebSocketMessage(msg: any): void { diff --git a/internal/api/server.go b/internal/api/server.go index 0f71b95..5b6919a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1226,7 +1226,7 @@ func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) { userName = user.DisplayName } - info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 12*time.Second) + info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 20*time.Second) if !acquired { w.WriteHeader(http.StatusLocked) _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -1249,7 +1249,7 @@ 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, 12*time.Second) + ok := s.wsHub.HeartbeatDocLock(docID, userID, 20*time.Second) writeJSON(w, http.StatusOK, map[string]bool{"extended": ok}) } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 5505944..399d85a 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -362,6 +362,10 @@ func (s *Store) migrate() error { "CREATE TABLE IF NOT EXISTS doc_user_folders (user_id TEXT NOT NULL, doc_id TEXT NOT NULL, folder_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, doc_id));", "CREATE INDEX IF NOT EXISTS idx_doc_user_folders_user ON doc_user_folders(user_id);", + + "CREATE TABLE IF NOT EXISTS workspace_folders (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, shared_folder_id TEXT NOT NULL DEFAULT '', path TEXT NOT NULL, sub_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL);", + "CREATE INDEX IF NOT EXISTS idx_workspace_folders_user ON workspace_folders(user_id);", + "CREATE INDEX IF NOT EXISTS idx_workspace_folders_sf ON workspace_folders(shared_folder_id);", } for _, q := range columnMigrations { _, _ = s.db.Exec(q) @@ -2101,14 +2105,39 @@ func (s *Store) GetFolders(userID string) ([]string, error) { // 2. Add mounted shared folders (so even empty shared folders appear in the tree) mounts, _, err := s.getUserFolderMounts(userID) if err == nil { - for _, mPath := range mounts { + for sfID, mPath := range mounts { if mPath != "" { addFolderSegments(mPath, folderSet) + + // 2b. Add all subfolders created in this shared folder + sRows, err := s.db.Query(`SELECT sub_path FROM workspace_folders WHERE shared_folder_id = ?`, sfID) + if err == nil { + for sRows.Next() { + var sp string + if err := sRows.Scan(&sp); err == nil && sp != "" { + fullP := mPath + "/" + sp + addFolderSegments(fullP, folderSet) + } + } + _ = sRows.Close() + } } } } - // 3. Scan physical directories on disk in docDir/userID + // 3. Add personal empty folders created by this user + pRows, err := s.db.Query(`SELECT path FROM workspace_folders WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID) + if err == nil { + for pRows.Next() { + var p string + if err := pRows.Scan(&p); err == nil && p != "" { + addFolderSegments(p, folderSet) + } + } + _ = pRows.Close() + } + + // 4. Scan physical directories on disk in docDir/userID if s.docDir != "" { userDir := filepath.Join(s.docDir, userID) if fi, err := os.Stat(userDir); err == nil && fi.IsDir() { @@ -2158,6 +2187,31 @@ func (s *Store) CreateFolder(userID, folderPath string) (string, error) { return "", fmt.Errorf("ungültiger Ordnerpfad") } + mounts, _, err := s.getUserFolderMounts(userID) + var sfID, subPath string + if err == nil { + sfID, subPath, _ = matchMountPath(clean, mounts) + } + + now := time.Now().UTC().Format(time.RFC3339) + id := domain.NewID("wfld") + + if sfID != "" { + var existsID string + err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE shared_folder_id = ? AND sub_path = ?`, sfID, subPath).Scan(&existsID) + if err == sql.ErrNoRows { + _, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, ?, ?, ?, ?)`, + id, userID, sfID, clean, subPath, now) + } + } else { + var existsID string + err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE user_id = ? AND path = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID, clean).Scan(&existsID) + if err == sql.ErrNoRows { + _, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, '', ?, '', ?)`, + id, userID, clean, now) + } + } + if s.docDir != "" { targetDir := filepath.Join(s.docDir, userID, clean) if err := os.MkdirAll(targetDir, 0755); err != nil { @@ -2201,6 +2255,21 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) { _, _ = s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, id, userID) } + // Clean up from workspace_folders + mounts, _, err := s.getUserFolderMounts(userID) + if err == nil { + sfID, subPath, isMatch := matchMountPath(clean, mounts) + if isMatch && sfID != "" { + if subPath == "" { + _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, sfID) + } else { + _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ? AND (sub_path = ? OR sub_path LIKE ?)`, + sfID, subPath, subPath+"/%") + } + } + } + _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, clean, clean+"/%") + // Remove physical folder and subdirectories from disk if s.docDir != "" { targetDir := filepath.Join(s.docDir, userID, clean) @@ -2367,6 +2436,28 @@ func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) } } + // Migrate any existing workspace_folders created by this user in cleanMount + fRows, err := s.db.Query(`SELECT id, path FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, cleanMount, cleanMount+"/%") + if err == nil { + type wfEntry struct{ id, path string } + var wfEntries []wfEntry + for fRows.Next() { + var wfe wfEntry + if err := fRows.Scan(&wfe.id, &wfe.path); err == nil { + wfEntries = append(wfEntries, wfe) + } + } + _ = fRows.Close() + + for _, wfe := range wfEntries { + subP := "" + if strings.HasPrefix(wfe.path, cleanMount+"/") { + subP = strings.TrimPrefix(wfe.path, cleanMount+"/") + } + _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, folderID, subP, wfe.id) + } + } + var ownerName string _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&ownerName) @@ -2646,6 +2737,7 @@ func (s *Store) DeleteSharedFolder(currentUserID, folderID string) error { _, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id = ?`, folderID) _, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ?`, folderID) _, _ = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ?`, folderID) + _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, folderID) _, err = s.db.Exec(`DELETE FROM workspace_shared_folders WHERE id = ?`, folderID) return err }