From 0bdeb5f320f8c44f095a7522ee52edd3687e8dea Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 14:31:54 +0100 Subject: [PATCH 1/6] fix(docs/storage): resolve FLAW-02, remove unused docDir and update documentation for pure database architecture --- Doku/README.md | 14 ++++++------- Doku/architecture.md | 4 ++-- Doku/database.md | 4 ++-- Doku/flaws.md | 19 +++++++++-------- Doku/modules/workspace.md | 37 ++++++++++++++++++--------------- internal/storage/sqlite.go | 6 +----- internal/storage/sqlite_test.go | 10 +-------- 7 files changed, 42 insertions(+), 52 deletions(-) diff --git a/Doku/README.md b/Doku/README.md index 66cbbe1..bea5359 100644 --- a/Doku/README.md +++ b/Doku/README.md @@ -17,7 +17,7 @@ Doku/ ├── modules/ │ ├── triage.md # Modul 1: Triage & Quick-Dump (State Machine & Audio) │ ├── tasks.md # Modul 2: Aufgaben & Kanban-System (Alarme & Scheduler) -│ ├── workspace.md # Modul 3: Workspace Outliner-Engine, Wiki-Links & Disk-Sync +│ ├── workspace.md # Modul 3: Workspace Outliner-Engine, Wiki-Links & Pure-DB Storage │ └── lists.md # Modul 4: Geteilte Listen (Live-Kollaboration & D&D) ├── sharing-and-permissions.md # Synchrones Teilen, Rollen, Isolation & Mount-Entkopplung ├── theming.md # Single-File CSS Theme-Engine & Design-System @@ -38,7 +38,7 @@ Doku/ 6. **[Kernmodul: Aufgaben & Kanban-System](modules/tasks.md)** Multi-Listen-Konzept, 3-Spalten Kanban-Board, Subtasks, Fälligkeiten und Multi-Channel Alarm-Ticker (SMTP, Discord, Webhooks). 7. **[Kernmodul: Workspace & Outliner-Engine](modules/workspace.md)** - Logseq-inspirierte hierarchische Block-Engine, Zoom-Hoisting, Wiki-Links, Disk-Mirroring (`.md`) und Globales To-Do Rollup. + Logseq-inspirierte hierarchische Block-Engine, Zoom-Hoisting, Wiki-Links, Pure-Database Storage und Globales To-Do Rollup. 8. **[Kernmodul: Geteilte Listen & Kollaboration](modules/lists.md)** Listen-Typen, Mengen, Kategorien, Live-Checkoff und Drag & Drop Sortierung. 9. **[Synchrones Teilen & Berechtigungskonzept](sharing-and-permissions.md)** @@ -46,7 +46,7 @@ Doku/ 10. **[Single-File Theming & Design-System](theming.md)** 1-Datei = 1-Theme Philosophie, die 6 CSS-Variablen-Blöcke, Semantische CSS-Klassen und Geometrie-Steuerung. 11. **[Technische Schwachstellen & Optimierungspotenziale (Flaws)](flaws.md)** - Kritische Analyse von Concurrency-Grenzen, Dual-Storage-Risiken, Undo/Redo, Volltextsuche und Skalierungs-Engpässen. + Kritische Analyse von Concurrency-Grenzen, WebSocket-Proxies, Undo/Redo, Volltextsuche und Skalierungs-Engpässen. --- @@ -68,12 +68,11 @@ graph TB ChiRouter["Chi Router (REST API)"] WSHub["Gorilla WebSocket Hub & Lock Manager"] AlarmTicker["Alarm Scheduler (Background Ticker)"] - DiskSync["Disk Mirror Engine (/data/workspace)"] + StorageEngine["Storage Layer (Domain & Repositories)"] end subgraph Storage ["Storage Layer"] SQLite[("SQLite WAL Database (/data/mindsift.db)")] - DiskFiles[("Physische Markdown Files (/data/workspace)")] end subgraph External ["Externe Schnittstellen"] @@ -86,9 +85,8 @@ graph TB WSClient -->|WSS Realtime| Traefik Traefik --> ChiRouter Traefik --> WSHub - ChiRouter --> SQLite - ChiRouter --> DiskSync - DiskSync --> DiskFiles + ChiRouter --> StorageEngine + StorageEngine --> SQLite AlarmTicker --> Discord AlarmTicker --> SMTPServer HomeAssistant -->|POST /api/v1/inbox| Traefik diff --git a/Doku/architecture.md b/Doku/architecture.md index aa1606c..2dd336f 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -138,5 +138,5 @@ MindSift wird als schlanker, autarker Docker-Container ausgeliefert: 3. **Stage 3 (Production Runtime):** `alpine:3.20` mit `ca-certificates` und `tzdata`. Das finale Image ist ca. 45 MB groß und benötigt keine externen Datenbankserver oder Abhängigkeiten. Alle persistenten Daten befinden sich in einem einzigen Verzeichnis `/data`: -- `/data/mindsift.db` (SQLite Hauptdatenbank) -- `/data/workspace/` (Physische Markdown-Dateien der Workspace-Notizen) +- `/data/mindsift.db` (SQLite Hauptdatenbank inklusive WAL-Journal und Shared-Memory-Dateien) +- Notizen und Workspace-Blöcke werden zu 100% in der SQLite-Datenbank persistiert (keine losen Markdown-Dateien auf der Festplatte). diff --git a/Doku/database.md b/Doku/database.md index fb49e55..3b73140 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -208,7 +208,7 @@ CREATE TABLE IF NOT EXISTS task_list_shares ( ### 2.4 Workspace: Dokumente, Blöcke & Geteilte Ordner #### `documents` & `document_blocks` (Database-First Outliner) -Dokumente werden als Kopfdatensatz in `documents` gespeichert, während der Inhalt als feingranulare Blöcke in `document_blocks` verwaltet wird. Zusätzlich spiegelt MindSift die Daten synchron als physische `.md`-Datei auf Festplatte. +Dokumente werden als Kopfdatensatz in `documents` gespeichert, während der Inhalt als feingranulare Blöcke in `document_blocks` verwaltet wird. MindSift speichert alle Notizen zu 100% in SQLite (keine physischen `.md`-Dateien auf Festplatte). ```sql CREATE TABLE IF NOT EXISTS documents ( @@ -216,7 +216,7 @@ CREATE TABLE IF NOT EXISTS documents ( user_id TEXT NOT NULL, -- Eigentümer title TEXT NOT NULL, -- Dokumenttitel icon TEXT NOT NULL DEFAULT '📄', -- Emoji-Icon - content TEXT NOT NULL DEFAULT '', -- Markdown-Volltext (Spiegelung) + content TEXT NOT NULL DEFAULT '', -- Plaintext / Text-Repräsentation (Cache) blocks_json TEXT NOT NULL DEFAULT '[]', -- Legacy JSON-Cache tags TEXT NOT NULL DEFAULT '[]', -- JSON Array von Tags folder_path TEXT NOT NULL DEFAULT '', -- Relativer Ordnerpfad des Eigentümers diff --git a/Doku/flaws.md b/Doku/flaws.md index c0739a7..4ba46bb 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -9,7 +9,7 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle | ID | Bereich | Problem / Schwachstelle | Schweregrad | Risiko im Produktivbetrieb | |---|---|---|---|---| | **FLAW-01** | Workspace Sync | Kein echtes CRDT/OT – Lock-Heartbeat-Modell mit Last-Write-Wins | **Kritisch** | Zeilenverlust bei gleichzeitigen Bearbeitungen oder Netzschwankungen | -| **FLAW-02** | Storage | Dual-Storage Drift (SQLite `document_blocks` vs. Dateisystem `.md`) | **Hoch** | Externe Änderungen an `.md` werden nicht automatisch in SQLite gespiegelt | +| **FLAW-02** | Storage | Dual-Storage Drift (SQLite vs. Dateisystem) | **Obsolet / Gelöst** | Kein Risiko: MindSift nutzt Pure-DB (keine `.md`-Dateien auf Platte) | | **FLAW-03** | Netzwerk / WS | Proxy-Inkompatibilität bei WebSocket-Upgrades & Polling-Fallback | **Mittel** | Verzögerte Synchronisierung (1–3s Polling) in Firmennetzwerken | | **FLAW-04** | Datenbank / Suche | Volltextsuche via `LIKE '%query%'` statt SQLite FTS5 | **Mittel** | Performance-Einbrüche bei wachsenden Notizbeständen (>10.000 Blöcke) | | **FLAW-05** | Frontend DOM | Fehlende Virtualisierung im Outliner-Baum | **Mittel** | Frame-Drops und Lags bei Dokumenten mit >500 Blöcken auf Mobilgeräten | @@ -24,7 +24,7 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle ## 🔍 Detaillierte Analyse & Maßnahmen ### FLAW-01: Kein echtes CRDT/OT – Lock-Heartbeat & Last-Write-Wins -- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`modules/workspace.md`](./modules/workspace.md), `internal/ws/hub.go`, `frontend/src/lib/components/Outliner.svelte` +- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`modules/workspace.md`](./modules/workspace.md), `internal/ws/hub.go`, `frontend/src/features/workspace/WorkspaceView.tsx` - **Ursache:** MindSift nutzt zur Vermeidung von Konflikten eine kooperative Bearbeitungssperre (*Document Lease Lock*, 30 Sekunden TTL mit Heartbeat). Wenn zwei Nutzer dasselbe Dokument geöffnet haben, blockiert der aktive Schreiber das Bearbeiten für andere. Verliert ein Nutzer jedoch kurzzeitig die WebSocket-Verbindung oder tippt während des Übergangs zum Polling-Fallback, werden Änderungen per REST via Last-Write-Wins (`PUT /api/v1/documents/:id`) gespeichert. - **Folgen:** Wie in früheren Versionen beobachtet, können bei parallelem Schreiben Zeilen überschrieben werden oder Strukturhierarchien (Parent-IDs) verloren gehen. - **Empfohlene Lösung:** @@ -33,13 +33,14 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle --- -### FLAW-02: Dual-Storage Drift (SQLite vs. Dateisystem `.md`) -- **Betroffene Komponenten:** [`database.md`](./database.md), `internal/storage/sqlite.go`, `internal/storage/markdown_sync.go` -- **Ursache:** MindSift pflegt Notizen primär in SQLite (`documents`, `document_blocks`), exportiert bzw. spiegelt sie jedoch als reine `.md`-Dateien auf das Dateisystem (`data/notes/`), um Exportierbarkeit und lokale Lesbarkeit zu gewährleisten. -- **Problem:** Es existiert kein systemweiter File-Watcher ([fsnotify](https://github.com/fsnotify/fsnotify)). Bearbeitet der Nutzer eine Notizdatei direkt auf dem Host (z. B. via Obsidian, VS Code, Git-Sync oder SSH), bekommt die SQLite-Datenbank dies nicht mit. Beim nächsten Web-Edit überschreibt SQLite die Datei auf der Festplatte. -- **Empfohlene Lösung:** - 1. Integrieren eines Background-File-Watchers mit `fsnotify` für das Datenverzeichnis. - 2. Implementieren einer Hash-Prüfung (`sha256` der Datei vs. DB-Metadaten) vor dem Speichern. Bei Abweichung: Nutzerkonflikt-Dialog anzeigen. +### FLAW-02: [OBSOLET / GELÖST] Dual-Storage Drift (SQLite vs. Dateisystem) +- **Status:** **Entkräftet & Behoben (Pure Database Architecture)** +- **Klarstellung:** In früheren Entwürfen wurde angedacht, Notizen zusätzlich als Markdown-Dateien auf dem Dateisystem zu spiegeln. MindSift speichert Notizen und Blöcke jedoch **zu 100% relational in SQLite** (`documents` und `document_blocks`). +- **Verifikation:** + - Es existieren **keine** Markdown-Dateien auf der Festplatte. + - Das Go-Backend schreibt keine Dateien in ein Notizverzeichnis. + - Unnötige Verzeichniserstellungen (`docDir`/`workspace`) wurden aus `internal/storage/sqlite.go` vollständig entfernt. +- **Ergebnis:** Es gibt keinen Dual-Storage und somit keinen File-Drift oder externe Dateikonflikte. Notizen verhalten sich vollständig transaktional und konsistent innerhalb der SQLite-Datenbank. --- diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index e433f0a..34889e6 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -1,6 +1,6 @@ -# 📝 Kernmodul 3: Workspace Outliner-Engine, Wiki-Links & Disk-Sync +# 📝 Kernmodul 3: Workspace Outliner-Engine, Wiki-Links & Pure-DB Storage -Der MindSift Workspace ([`WorkspaceView.tsx`](../../frontend/src/features/workspace/WorkspaceView.tsx)) kombiniert ein **hierarchisches Bullet-Point Notizsystem (inspiriert von Logseq)** mit einem **persönlichen Markdown-Wiki** und einem **globalen To-Do Rollup Dashboard**. +Der MindSift Workspace ([`WorkspaceView.tsx`](../../frontend/src/features/workspace/WorkspaceView.tsx)) kombiniert ein **hierarchisches Bullet-Point Notizsystem (inspiriert von Logseq)** mit einem **persönlichen Wiki** und einem **globalen To-Do Rollup Dashboard**. --- @@ -46,30 +46,33 @@ Der Bullet-Dot dient gleichzeitig als Drag-Anfasser. Wird ein Block verschoben, --- -## 💾 3. Dual-Storage: Database-First & Disk-Mirroring +## 💾 3. Pure-Database Architecture (Keine MD-Dateien auf Platte) -MindSift löst das Dilemma zwischen schneller relationaler Abfrage und portabler Dateispeicherung durch ein **synchrones Dual-Storage-Modell**: +MindSift speichert Notizen und Outliner-Bäume **zu 100% relational in SQLite**. Es werden bewusst **keine Markdown-Dateien auf dem Dateisystem** abgelegt: ``` Client (Block-Editor) - │ PATCH /api/v1/documents/:id/blocks + │ PUT /api/v1/documents/:id/blocks (oder WebSocket) ▼ Backend Engine ┌──────────────────────────────────────────────┐ - │ 1. Transaktion in SQLite (document_blocks) │ - │ -> Sort-Order, Content, Level, Checked │ - │ 2. Volltext-Generierung (Markdown-String) │ - │ 3. Schreiben auf Disk (/data/workspace/...) │ + │ Atomare Transaktion in SQLite: │ + │ 1. DELETE FROM document_blocks WHERE doc_id │ + │ 2. Batch INSERT der Blöcke: │ + │ -> id, doc_id, content, level, checked, │ + │ sort_order, collapsed │ + │ 3. WebSocket Broadcast an Raum (doc:id) │ └──────────────────────────────────────────────┘ ``` -1. **Präzision & Geschwindigkeit (SQLite):** - - Jeder Block besitzt eine eindeutige ID (`blk_...`) und eine Gleitkomma-Reihenfolge (`sort_order REAL`). - - Schnelle Status-Updates (z.B. Checkbox abhaken) schreiben nur genau eine Zeile in die Datenbank. -2. **Datensouveränität & Portabilität (Disk-Mirror):** - - Zeitgleich spiegelt der Server das Dokument als Standard-`.md`-Datei unter `/data/workspace///.md`. - - Einrückungen werden nach dem 2-Leerzeichen-Standard (` - [ ] `) formatiert. - - Administratoren können das Verzeichnis jederzeit mit externen Werkzeugen (Obsidian, Logseq, Git, rsync) sichern. +### Vorteile der reinen Datenbank-Architektur +1. **Kein Dual-Storage & kein Drift:** + - Keine Inkonsistenzen zwischen Festplattendateien und Datenbankzeilen. + - Keine Dateisystem-Sperren, Race-Conditions oder Berechtigungskonflikte auf Betriebssystemebene. +2. **Atomare Transaktionen (ACID):** + - Strukturänderungen (Einrücken, Unterbaum verschieben, Blöcke löschen) sind atomar in einer Transaktion gekapselt. +3. **Skalierbare Abfragen & Rollups:** + - Aggregationen (wie das globale To-Do Dashboard oder Tag-Suchen) erfolgen mit hochoptimierten SQL-Abfragen über relationale Indizes, anstatt Hunderte `.md`-Dateien von der Festplatte parsen zu müssen. --- @@ -79,4 +82,4 @@ Client (Block-Editor) - Scannt in Echtzeit alle unvollendeten `- [ ] ` Checkboxen über alle Ordner und Notizen hinweg. - Übersichtliche Gruppierung nach **Ordner ➔ Dokument ➔ Aufgabe**. - **Ordner-Filter:** Klick auf einen Ordner isoliert nur dessen Aufgaben für fokussiertes Arbeiten. -- **2-Wege-Live-Sync:** Wird eine Checkbox im Rollup abgehakt, wird der Block in SQLite sofort auf `checked = 1` aktualisiert und die physische `.md`-Datei synchronisiert. +- **2-Wege-Live-Sync:** Wird eine Checkbox im Rollup abgehakt, wird der Block in SQLite sofort auf `checked = 1` aktualisiert und via WebSocket an alle aktiven Clients gebroadcastet. diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index f06db3b..8942526 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -21,19 +21,15 @@ import ( type Store struct { db *sql.DB - docDir string initMu sync.Mutex } func NewStore(dbPath string) (*Store, error) { - var docDir string if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") { dir := filepath.Dir(dbPath) if err := os.MkdirAll(dir, 0755); err != nil { return nil, fmt.Errorf("failed to create data directory %s: %w", dir, err) } - docDir = filepath.Join(dir, "workspace") - _ = os.MkdirAll(docDir, 0755) } dsn := dbPath @@ -49,7 +45,7 @@ func NewStore(dbPath string) (*Store, error) { db.SetMaxOpenConns(8) db.SetMaxIdleConns(4) - store := &Store{db: db, docDir: docDir} + store := &Store{db: db} if err := store.migrate(); err != nil { return nil, fmt.Errorf("failed to run migrations: %w", err) } diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 07667c0..70fdea0 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -1,7 +1,6 @@ package storage import ( - "os" "path/filepath" "testing" @@ -332,7 +331,6 @@ func TestEnsureDemoAccountsAndLoginLookup(t *testing.T) { func TestFolderManagementAndDeletion(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "folder_test.db") - docDir := filepath.Join(tmpDir, "workspace") store, err := NewStore(dbPath) if err != nil { @@ -345,7 +343,7 @@ func TestFolderManagementAndDeletion(t *testing.T) { t.Fatalf("CreateUser failed: %v", err) } - // 1. Create folders on disk + // 1. Create folders f1, err := store.CreateFolder(user.ID, "Projekte/MindSift") if err != nil || f1 != "Projekte/MindSift" { t.Fatalf("CreateFolder failed: %v, f1=%s", err, f1) @@ -401,12 +399,6 @@ func TestFolderManagementAndDeletion(t *testing.T) { if err == nil { t.Fatalf("expected doc2 to be deleted") } - - // Verify disk directory was deleted - diskFolder := filepath.Join(docDir, user.ID, "Projekte/MindSift") - if _, err := os.Stat(diskFolder); !os.IsNotExist(err) { - t.Fatalf("expected directory %s to be deleted on disk", diskFolder) - } } func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) { -- 2.54.0 From ef8cf8f022bc9eacf21a62ef3f00b12e8f106ca6 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 15:18:48 +0100 Subject: [PATCH 2/6] feat(workspace): implement Yjs CRDT real-time collaborative editing (resolves FLAW-01) --- Doku/api/websocket.md | 60 ++-- Doku/flaws.md | 19 +- Doku/modules/workspace.md | 51 ++-- frontend/package-lock.json | 51 +++- frontend/package.json | 3 +- .../src/features/workspace/WorkspaceView.tsx | 194 +++++++++++-- frontend/src/lib/api.ts | 11 + frontend/src/lib/crdt.ts | 271 ++++++++++++++++++ internal/api/server.go | 70 +++++ internal/api/server_test.go | 70 +++++ internal/storage/sqlite.go | 72 +++++ internal/storage/sqlite_test.go | 59 ++++ internal/ws/hub.go | 182 +++++++++++- 13 files changed, 1028 insertions(+), 85 deletions(-) create mode 100644 frontend/src/lib/crdt.ts diff --git a/Doku/api/websocket.md b/Doku/api/websocket.md index c1f4fb1..e90cfa3 100644 --- a/Doku/api/websocket.md +++ b/Doku/api/websocket.md @@ -64,43 +64,53 @@ Alle WebSocket-Nachrichten werden als JSON mit einheitlicher Hülle übertragen: --- -## 🔒 4. Dokument-Sperrsystem (Document Locking) +--- -Um Schreibkonflikte bei Notizen im Workspace zu vermeiden, verwendet MindSift ein **serverseitiges Lease-Locking mit Heartbeat**: +## ⚡ 4. CRDT-Echtzeitprotokoll (Yjs Collaborative Editing) + +Seit Version 2.1 unterstützt MindSift **echte, konfliktfreie Mehrbenutzer-Kollaboration** für Notizen via Yjs CRDT. Clients tauschen binäre Delta-Updates und Live-Awareness-Informationen über dedizierte WebSocket-Nachrichten aus: + +### CRDT-Nachrichtentypen +| Event | Richtung | Zweck / Payload | +|---|---|---| +| `CRDT_JOIN` | Client ➔ Server | Tritt dem Dokumentenraum (`doc:{docId}`) bei. | +| `CRDT_INIT` | Server ➔ Client | Sendet den aktuellen Basiszustand (`stateBlob`) und offene Delta-Updates (`updates[]`) an den Client. | +| `CRDT_UPDATE` | Bidirektional | Überträgt inkrementelle binäre Yjs-Updates (Base64-kodiert). Server speichert in `document_crdt_updates` und verteilt an alle anderen Raumnutzer. | +| `CRDT_COMPACT` | Client ➔ Server | Komprimiert mehrere Updates zu einem konsolidierten Snapshot in `document_crdt_state`. | +| `CRDT_AWARENESS` | Bidirektional | Überträgt Live-Präsenz (Fokus auf `blockId`, Benutzername, Farbe). | +| `CRDT_AWARENESS_LEAVE` | Bidirektional | Signalisiert, dass ein Nutzer den Fokus verloren hat oder das Dokument verlassen hat. | +| `CRDT_LEAVE` | Client ➔ Server | Verlässt den Dokumentenraum explizit. | ```mermaid sequenceDiagram autonumber actor UserA as Benutzer A - participant WS as WebSocket Hub + participant WS as WebSocket Hub & SQLite actor UserB as Benutzer B - UserA->>WS: LOCK_DOC (docId: "doc_123") - WS-->>UserA: Lock bestätigt (30s TTL) - WS->>UserB: DOC_LOCKED (docId: "doc_123", lockedBy: "Benutzer A") - Note over UserB: Editor gesperrt (Schreibschutz aktiv) + UserA->>WS: CRDT_JOIN (docId: "doc_123") + WS-->>UserA: CRDT_INIT (stateBlob, updates) + UserB->>WS: CRDT_JOIN (docId: "doc_123") + WS-->>UserB: CRDT_INIT (stateBlob, updates) - loop Alle 15 Sekunden - UserA->>WS: HEARTBEAT_DOC (docId: "doc_123") - WS-->>UserA: TTL um 30s verlängert - end + UserA->>WS: CRDT_UPDATE (docId, updateBlob) + WS->>WS: Append in document_crdt_updates + WS->>UserB: CRDT_UPDATE (docId, updateBlob) + Note over UserB: Yjs wendet Delta atomar an (0 Konflikte) - alt Reguläres Schließen - UserA->>WS: UNLOCK_DOC (docId: "doc_123") - WS->>UserB: DOC_UNLOCKED (docId: "doc_123") - Note over UserB: Editor wieder freigegeben - else Verbindungsabbruch / Tab geschlossen - Note over WS: Heartbeat bleibt aus (TTL läuft ab) - WS->>UserB: DOC_UNLOCKED (Automatisch nach Ablauf) - end + UserA->>WS: CRDT_AWARENESS (docId, focusedBlockId) + WS->>UserB: CRDT_AWARENESS (docId, user, focusedBlockId) + Note over UserB: Avatar-Badge erscheint am Block ``` -### Locking-Events -- **`LOCK_DOC` (Client ➔ Server):** Fordert eine 30-Sekunden-Sperre für ein Dokument an. -- **`HEARTBEAT_DOC` (Client ➔ Server):** Verlängert die Sperre alle 15 Sekunden, solange der Benutzer das Dokument geöffnet hat. -- **`UNLOCK_DOC` (Client ➔ Server):** Gibt die Sperre beim Verlassen des Dokuments frei. -- **`DOC_LOCKED` (Server ➔ Clients):** Informiert andere Benutzer, dass das Dokument schreibgeschützt ist. -- **`DOC_UNLOCKED` (Server ➔ Clients):** Hebt den Schreibschutz auf. +--- + +## 🔒 5. Abwärtskompatibles Document-Lease-Locking + +Vor Version 2.1 nutzte MindSift ein serverseitiges Lease-Locking mit 30-Sekunden-Heartbeats. Durch die CRDT-Engine wurde die blockierende Bearbeitungssperre (`isLockedByOther`) abgelöst: +- **Aktuelles Verhalten:** Mehrere Benutzer mit Bearbeitungsrechten können zeitgleich dasselbe Dokument bearbeiten. +- **Presence & Locks:** Die Events `LOCK_DOC` und `HEARTBEAT_DOC` bleiben abwärtskompatibel erhalten und dienen nun der reinen Präsenzanzeige (z.B. gelber Statusindikator „wird gerade von X angesehen"). +- **Schreibschutz:** Ein echter Schreibschutz (`isReadOnly`) greift ausschließlich noch dann, wenn der Nutzer nur über Leserechte (`permission === 'read'`) für die Notiz oder den Ordner verfügt. --- diff --git a/Doku/flaws.md b/Doku/flaws.md index 4ba46bb..c59a7b3 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -8,7 +8,7 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle | ID | Bereich | Problem / Schwachstelle | Schweregrad | Risiko im Produktivbetrieb | |---|---|---|---|---| -| **FLAW-01** | Workspace Sync | Kein echtes CRDT/OT – Lock-Heartbeat-Modell mit Last-Write-Wins | **Kritisch** | Zeilenverlust bei gleichzeitigen Bearbeitungen oder Netzschwankungen | +| **FLAW-01** | Workspace Sync | Kein echtes CRDT/OT – Lock-Heartbeat-Modell mit Last-Write-Wins | **Gelöst (v2.1)** | Behoben durch Yjs CRDT-Engine & blockbasierte kollaborative Bearbeitung | | **FLAW-02** | Storage | Dual-Storage Drift (SQLite vs. Dateisystem) | **Obsolet / Gelöst** | Kein Risiko: MindSift nutzt Pure-DB (keine `.md`-Dateien auf Platte) | | **FLAW-03** | Netzwerk / WS | Proxy-Inkompatibilität bei WebSocket-Upgrades & Polling-Fallback | **Mittel** | Verzögerte Synchronisierung (1–3s Polling) in Firmennetzwerken | | **FLAW-04** | Datenbank / Suche | Volltextsuche via `LIKE '%query%'` statt SQLite FTS5 | **Mittel** | Performance-Einbrüche bei wachsenden Notizbeständen (>10.000 Blöcke) | @@ -23,13 +23,16 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle ## 🔍 Detaillierte Analyse & Maßnahmen -### FLAW-01: Kein echtes CRDT/OT – Lock-Heartbeat & Last-Write-Wins -- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`modules/workspace.md`](./modules/workspace.md), `internal/ws/hub.go`, `frontend/src/features/workspace/WorkspaceView.tsx` -- **Ursache:** MindSift nutzt zur Vermeidung von Konflikten eine kooperative Bearbeitungssperre (*Document Lease Lock*, 30 Sekunden TTL mit Heartbeat). Wenn zwei Nutzer dasselbe Dokument geöffnet haben, blockiert der aktive Schreiber das Bearbeiten für andere. Verliert ein Nutzer jedoch kurzzeitig die WebSocket-Verbindung oder tippt während des Übergangs zum Polling-Fallback, werden Änderungen per REST via Last-Write-Wins (`PUT /api/v1/documents/:id`) gespeichert. -- **Folgen:** Wie in früheren Versionen beobachtet, können bei parallelem Schreiben Zeilen überschrieben werden oder Strukturhierarchien (Parent-IDs) verloren gehen. -- **Empfohlene Lösung:** - 1. Langfristige Migration auf ein echtes blockweises CRDT-Framework (wie [Yjs](https://yjs.dev/) oder [Automerge](https://automerge.org/)). - 2. Kurzfristig: Optimistisches Block-Locking (Sperre nur auf Block-Ebene `block_id` statt des gesamten Dokuments) und atomare Versionsprüfung (*Optimistic Concurrency Control* mit `version_seq`). +### FLAW-01: [GELÖST / IMPLEMENTIERT] Yjs Outliner CRDT & Multi-User Kollaboration +- **Status:** **Gelöst via Yjs CRDT Engine (Lösung i)** +- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`modules/workspace.md`](./modules/workspace.md), `internal/ws/hub.go`, `internal/storage/sqlite.go`, `frontend/src/lib/crdt.ts`, `frontend/src/features/workspace/WorkspaceView.tsx` +- **Ursache (Historisch):** MindSift nutzte ein kooperatives Dokumenten-Lease-Locking mit Last-Write-Wins. Bei parallelem Zugriff oder Netztrennung konnten Änderungen überschrieben werden. +- **Implementierte Lösung (Yjs CRDT):** + 1. **Block-Level Yjs CRDT:** Implementierung eines vollwertigen CRDT-Modells mit `Y.Doc`. Die Blockreihenfolge wird über ein `Y.Array` (Block-IDs) konfliktfrei mit dem YATA-Algorithmus aufgelöst. Inhalte und Metadaten (Text, Level, Checked, Collapsed) sind in `Y.Map` gekapselt. + 2. **Wegfall von Ganzdokument-Sperren:** Keine blockierenden Sperren mehr (`isLockedByOther` entfernt). Mehrere Nutzer können zeitgleich am selben Dokument schreiben. + 3. **Dual-Persistence:** CRDT-Zustände und Delta-Updates werden binär/Base64 in SQLite (`document_crdt_state`, `document_crdt_updates`) persistiert. Zur Aufrechterhaltung relationaler Indizes (Global To-Dos, Tag-Indizierung, Suche) werden Blöcke parallel in `document_blocks` materialisiert. + 4. **Live-Awareness & Presence:** Block-Fokus und Cursor-Zustand werden via `CRDT_AWARENESS` übertragen. Nutzer sehen ein Live-Badge im Header (`Live (N)`) sowie farbige Kollaborator-Pillen an aktiven Blöcken. + 5. **REST- & Proxy-Sicherheit:** Ergänzend zu WebSockets stehen Endpunkte `GET /api/v1/documents/:id/crdt` und `PUT /api/v1/documents/:id/crdt` bereit. --- diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index 34889e6..be55f6a 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -46,33 +46,40 @@ Der Bullet-Dot dient gleichzeitig als Drag-Anfasser. Wird ein Block verschoben, --- -## 💾 3. Pure-Database Architecture (Keine MD-Dateien auf Platte) +--- -MindSift speichert Notizen und Outliner-Bäume **zu 100% relational in SQLite**. Es werden bewusst **keine Markdown-Dateien auf dem Dateisystem** abgelegt: +## ⚡ 3. Yjs CRDT Real-Time Engine & Dual-Persistence +Ab MindSift v2.1 setzt der Workspace auf ein **konfliktfreies Replicated Data Type (CRDT) Modell** auf Basis von [Yjs](https://yjs.dev/): + +### Funktionsweise +1. **CRDT-Struktur im Client:** + - Ein `Y.Doc` pro geöffnetem Dokument. + - `Y.Array` (`blocks`): Führt die Block-IDs in korrekter Reihenfolge. Der YATA-Algorithmus garantiert, dass simultanes Einfügen, Verschieben oder Löschen von Blöcken durch mehrere Nutzer deterministisch und konfliktfrei konvergiert. + - `Y.Map` (`block:`): Speichert Inhalt (`content`), Einrückung (`level`), Erledigungsstatus (`checked`) und Faltungsstatus (`collapsed`). +2. **Kollaborative Bearbeitung ohne Sperren:** + - Nutzer können simultan dieselbe Notiz bearbeiten. + - Fokus- und Cursor-Positionen werden via `CRDT_AWARENESS` übertragen und als farbige Benutzer-Pillen direkt am jeweiligen Block visualisiert. + +### Dual-Persistence in SQLite ``` -Client (Block-Editor) - │ PUT /api/v1/documents/:id/blocks (oder WebSocket) - ▼ - Backend Engine - ┌──────────────────────────────────────────────┐ - │ Atomare Transaktion in SQLite: │ - │ 1. DELETE FROM document_blocks WHERE doc_id │ - │ 2. Batch INSERT der Blöcke: │ - │ -> id, doc_id, content, level, checked, │ - │ sort_order, collapsed │ - │ 3. WebSocket Broadcast an Raum (doc:id) │ - └──────────────────────────────────────────────┘ +Client (Y.Doc Mutation) + │ + ├──▶ WebSocket (CRDT_UPDATE Base64 Delta) + │ │ + │ ▼ + │ SQLite: document_crdt_updates / document_crdt_state + │ (Mathematisch exakte CRDT-Konvergenz) + │ + └──▶ Periodischer Snapshot / Flush + │ + ▼ + SQLite: document_blocks + (Relationale Indizes für Global To-Dos, Tag-Explorer & FTS) ``` -### Vorteile der reinen Datenbank-Architektur -1. **Kein Dual-Storage & kein Drift:** - - Keine Inkonsistenzen zwischen Festplattendateien und Datenbankzeilen. - - Keine Dateisystem-Sperren, Race-Conditions oder Berechtigungskonflikte auf Betriebssystemebene. -2. **Atomare Transaktionen (ACID):** - - Strukturänderungen (Einrücken, Unterbaum verschieben, Blöcke löschen) sind atomar in einer Transaktion gekapselt. -3. **Skalierbare Abfragen & Rollups:** - - Aggregationen (wie das globale To-Do Dashboard oder Tag-Suchen) erfolgen mit hochoptimierten SQL-Abfragen über relationale Indizes, anstatt Hunderte `.md`-Dateien von der Festplatte parsen zu müssen. +1. **Mathematische CRDT-Garantie:** Binäre Yjs-Updates werden in `document_crdt_updates` und periodisch als Snapshot in `document_crdt_state` gesichert. Auch nach einem Server-Neustart oder Offline-Phasen konvergieren alle Clients verlustfrei. +2. **Relationale Query-Fähigkeit:** Um schnelle SQL-Abfragen für das globale To-Do Rollup Dashboard und Hashtags zu ermöglichen, werden die Blöcke parallel in `document_blocks` materialisiert. Es existieren **keine Markdown-Dateien auf der Festplatte**. --- diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2201d64..11ddca9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,7 +13,8 @@ "lucide-react": "^0.468.0", "prismjs": "^1.30.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "yjs": "^13.6.33" }, "devDependencies": { "@types/react": "^18.3.12", @@ -1840,6 +1841,16 @@ "node": ">=0.12.0" } }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -1882,6 +1893,27 @@ "node": ">=6" } }, + "node_modules/lib0": { + "version": "0.2.119", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.119.tgz", + "integrity": "sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lilconfig": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.4.tgz", @@ -2756,6 +2788,23 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yjs": { + "version": "13.6.33", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.33.tgz", + "integrity": "sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } } } } diff --git a/frontend/package.json b/frontend/package.json index ee43213..a4ff258 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,8 @@ "lucide-react": "^0.468.0", "prismjs": "^1.30.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "yjs": "^13.6.33" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index b8973fc..bfb0904 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -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 = ({ const [showFolderShareModal, setShowFolderShareModal] = useState(false); const [folderShares, setFolderShares] = useState([]); - // --- DOCUMENT LOCKING STATE --- + // --- DOCUMENT CRDT & COLLABORATION STATE --- + const crdtSessionRef = useRef(null); + const [activePeers, setActivePeers] = useState([]); + + // --- DOCUMENT LOCKING STATE (FALLBACK) --- const [activeLock, setActiveLock] = useState(null); const [holdsLock, setHoldsLock] = useState(false); const holdsLockRef = useRef(false); @@ -228,8 +233,9 @@ export const WorkspaceView: React.FC = ({ const blockInputRefs = useRef>({}); - 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>(new Map()); @@ -418,12 +424,34 @@ export const WorkspaceView: React.FC = ({ 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 = ({ const handleBlockBlur = () => { isEditorFocusedRef.current = false; + broadcastAwareness(null); if (idleUnlockTimerRef.current) { clearTimeout(idleUnlockTimerRef.current); } @@ -611,6 +640,11 @@ export const WorkspaceView: React.FC = ({ } }); + // 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 = ({ const currentUserIdRef = useRef(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 = ({ 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 = ({ 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 = ({ 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 = ({ )} - {holdsLock && ( + {activePeers.length > 0 ? ( + c.userName).join(', ')}`} + > + + + Live ({activePeers.length}) + + ) : holdsLock ? ( - Schreibzugriff aktiv + CRDT Sync aktiv - )} - - {isLockedByOther && ( - - - Wird bearbeitet ({activeLock?.userName || 'anderer Nutzer'}) - - )} + ) : null} {selectedDoc.sharedWith && selectedDoc.sharedWith.length > 0 && selectedDoc.isOwner !== false && (