Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com

feat: seed OT engine with document text on room creation

+231 -3
+19
internal/collaboration/hub.go
··· 19 19 unregister chan *Client 20 20 mu sync.RWMutex 21 21 ot *OTEngine 22 + seeded bool // true after SeedText has been called 22 23 } 23 24 24 25 // broadcastMsg carries a message and an optional sender to exclude. ··· 177 178 } 178 179 r.Broadcast(data) 179 180 } 181 + 182 + // IsNewRoom returns true if SeedText has not yet been called on this room. 183 + func (r *Room) IsNewRoom() bool { 184 + r.mu.RLock() 185 + defer r.mu.RUnlock() 186 + return !r.seeded 187 + } 188 + 189 + // SeedText sets the initial document text for the OT engine. 190 + // Idempotent — only the first call has effect. 191 + func (r *Room) SeedText(text string) { 192 + r.mu.Lock() 193 + defer r.mu.Unlock() 194 + if !r.seeded { 195 + r.ot.SetText(text) 196 + r.seeded = true 197 + } 198 + }
+27
internal/collaboration/hub_test.go
··· 399 399 } 400 400 } 401 401 402 + // --- SeedText / IsNewRoom --- 403 + 404 + func TestRoom_SeedText_SetsInitialOTState(t *testing.T) { 405 + hub := NewHub() 406 + room := hub.GetOrCreateRoom("doc-seed") 407 + if !room.IsNewRoom() { 408 + t.Fatal("new room should report IsNewRoom=true") 409 + } 410 + room.SeedText("initial document content") 411 + if room.IsNewRoom() { 412 + t.Error("IsNewRoom should be false after seeding") 413 + } 414 + if got := room.ot.GetText(); got != "initial document content" { 415 + t.Errorf("OT text after seed: got %q, want %q", got, "initial document content") 416 + } 417 + } 418 + 419 + func TestRoom_SeedText_IdempotentAfterFirstCall(t *testing.T) { 420 + hub := NewHub() 421 + room := hub.GetOrCreateRoom("doc-seed-idem") 422 + room.SeedText("first") 423 + room.SeedText("second") // should be ignored 424 + if got := room.ot.GetText(); got != "first" { 425 + t.Errorf("second SeedText should be ignored: got %q, want %q", got, "first") 426 + } 427 + } 428 + 402 429 // --- GetPresence --- 403 430 404 431 func TestRoom_GetPresence_Empty(t *testing.T) {
+13 -3
internal/collaboration/ot.go
··· 44 44 } 45 45 46 46 func (ot *OTEngine) applyInternal(op Operation) (string, bool) { 47 - if op.Insert == "" { 48 - return ot.documentText, false 49 - } 50 47 if op.From < 0 { 51 48 op.From = 0 52 49 } ··· 61 58 op.To = len(ot.documentText) 62 59 } 63 60 if op.From > op.To { 61 + return ot.documentText, false 62 + } 63 + 64 + // True no-op: nothing deleted and nothing inserted. 65 + if op.From == op.To && op.Insert == "" { 64 66 return ot.documentText, false 65 67 } 66 68 ··· 69 71 ot.version++ 70 72 71 73 return newText, true 74 + } 75 + 76 + // SetText replaces the canonical document text without incrementing the version. 77 + // Call this once when a room is first created, before any edits are applied. 78 + func (ot *OTEngine) SetText(text string) { 79 + ot.mu.Lock() 80 + defer ot.mu.Unlock() 81 + ot.documentText = text 72 82 } 73 83 74 84 func (ot *OTEngine) GetText() string {
+165
internal/collaboration/ot_test.go
··· 1 + package collaboration 2 + 3 + import ( 4 + "sync" 5 + "testing" 6 + ) 7 + 8 + func TestOTEngine_NewEngine(t *testing.T) { 9 + ot := NewOTEngine("hello world") 10 + if ot.GetText() != "hello world" { 11 + t.Errorf("expected initial text %q, got %q", "hello world", ot.GetText()) 12 + } 13 + if ot.GetVersion() != 0 { 14 + t.Errorf("expected initial version 0, got %d", ot.GetVersion()) 15 + } 16 + } 17 + 18 + func TestOTEngine_Apply_Insert(t *testing.T) { 19 + ot := NewOTEngine("hello world") 20 + result := ot.Apply(Operation{From: 5, To: 5, Insert: " beautiful"}) 21 + want := "hello beautiful world" 22 + if result != want { 23 + t.Errorf("Apply insert: got %q, want %q", result, want) 24 + } 25 + } 26 + 27 + func TestOTEngine_Apply_Replace(t *testing.T) { 28 + ot := NewOTEngine("hello world") 29 + result := ot.Apply(Operation{From: 6, To: 11, Insert: "Go"}) 30 + want := "hello Go" 31 + if result != want { 32 + t.Errorf("Apply replace: got %q, want %q", result, want) 33 + } 34 + } 35 + 36 + func TestOTEngine_Apply_Delete(t *testing.T) { 37 + ot := NewOTEngine("hello world") 38 + // Delete " world" by replacing with empty string. 39 + result := ot.Apply(Operation{From: 5, To: 11, Insert: ""}) 40 + want := "hello" 41 + if result != want { 42 + t.Errorf("Apply delete (empty insert): got %q, want %q", result, want) 43 + } 44 + } 45 + 46 + func TestOTEngine_Apply_DeleteIncrementsVersion(t *testing.T) { 47 + ot := NewOTEngine("hello world") 48 + ot.Apply(Operation{From: 5, To: 11, Insert: ""}) 49 + if ot.GetVersion() != 1 { 50 + t.Errorf("delete should increment version: got %d, want 1", ot.GetVersion()) 51 + } 52 + } 53 + 54 + func TestOTEngine_Apply_EmptyInsertAtSamePosition_IsNoOp(t *testing.T) { 55 + ot := NewOTEngine("hello") 56 + result := ot.Apply(Operation{From: 3, To: 3, Insert: ""}) 57 + if result != "hello" { 58 + t.Errorf("From==To and Insert==\"\" should be no-op: got %q", result) 59 + } 60 + if ot.GetVersion() != 0 { 61 + t.Errorf("no-op should not increment version: got %d", ot.GetVersion()) 62 + } 63 + } 64 + 65 + func TestOTEngine_Apply_FullReplace(t *testing.T) { 66 + ot := NewOTEngine("old text") 67 + result := ot.Apply(Operation{From: 0, To: -1, Insert: "brand new content"}) 68 + want := "brand new content" 69 + if result != want { 70 + t.Errorf("Apply full replace (To=-1): got %q, want %q", result, want) 71 + } 72 + } 73 + 74 + func TestOTEngine_Apply_FromBeyondEnd_Clamps(t *testing.T) { 75 + ot := NewOTEngine("hi") 76 + // From beyond length should be clamped to len(text) 77 + result := ot.Apply(Operation{From: 100, To: 200, Insert: "!"}) 78 + want := "hi!" 79 + if result != want { 80 + t.Errorf("Apply clamped From: got %q, want %q", result, want) 81 + } 82 + } 83 + 84 + func TestOTEngine_Apply_NegativeFrom_Clamps(t *testing.T) { 85 + ot := NewOTEngine("hello") 86 + result := ot.Apply(Operation{From: -5, To: 0, Insert: ">> "}) 87 + want := ">> hello" 88 + if result != want { 89 + t.Errorf("Apply negative From: got %q, want %q", result, want) 90 + } 91 + } 92 + 93 + func TestOTEngine_Apply_FromGreaterThanTo_NoOp(t *testing.T) { 94 + ot := NewOTEngine("hello") 95 + result := ot.Apply(Operation{From: 3, To: 1, Insert: "X"}) 96 + // From > To → no-op 97 + if result != "hello" { 98 + t.Errorf("Apply From>To should be no-op: got %q, want %q", result, "hello") 99 + } 100 + } 101 + 102 + func TestOTEngine_VersionIncrements(t *testing.T) { 103 + ot := NewOTEngine("abc") 104 + if ot.GetVersion() != 0 { 105 + t.Fatalf("initial version should be 0") 106 + } 107 + ot.Apply(Operation{From: 0, To: 0, Insert: "X"}) 108 + if ot.GetVersion() != 1 { 109 + t.Errorf("version after apply: got %d, want 1", ot.GetVersion()) 110 + } 111 + ot.Apply(Operation{From: 0, To: 0, Insert: "Y"}) 112 + if ot.GetVersion() != 2 { 113 + t.Errorf("version after second apply: got %d, want 2", ot.GetVersion()) 114 + } 115 + } 116 + 117 + func TestOTEngine_NoOpDoesNotIncrementVersion(t *testing.T) { 118 + ot := NewOTEngine("abc") 119 + ot.Apply(Operation{From: 1, To: 1, Insert: ""}) // no-op 120 + if ot.GetVersion() != 0 { 121 + t.Errorf("no-op should not increment version: got %d, want 0", ot.GetVersion()) 122 + } 123 + } 124 + 125 + func TestOTEngine_ApplyWithVersion(t *testing.T) { 126 + ot := NewOTEngine("hello") 127 + text, ver := ot.ApplyWithVersion(Operation{From: 5, To: 5, Insert: "!"}) 128 + if text != "hello!" { 129 + t.Errorf("ApplyWithVersion text: got %q, want %q", text, "hello!") 130 + } 131 + if ver != 1 { 132 + t.Errorf("ApplyWithVersion version: got %d, want 1", ver) 133 + } 134 + } 135 + 136 + func TestOTEngine_SetText(t *testing.T) { 137 + ot := NewOTEngine("") 138 + ot.SetText("initial content") 139 + if ot.GetText() != "initial content" { 140 + t.Errorf("SetText: got %q, want %q", ot.GetText(), "initial content") 141 + } 142 + // SetText should not change the version counter. 143 + if ot.GetVersion() != 0 { 144 + t.Errorf("SetText should not increment version: got %d", ot.GetVersion()) 145 + } 146 + } 147 + 148 + func TestOTEngine_ConcurrentApply_DataRace(t *testing.T) { 149 + // Run with -race to catch races. This test just verifies no panic / race. 150 + ot := NewOTEngine("start") 151 + var wg sync.WaitGroup 152 + for i := 0; i < 20; i++ { 153 + wg.Add(1) 154 + go func() { 155 + defer wg.Done() 156 + ot.Apply(Operation{From: 0, To: 0, Insert: "x"}) 157 + }() 158 + } 159 + wg.Wait() 160 + // After 20 inserts of "x" at position 0, we have 20 extra chars 161 + text := ot.GetText() 162 + if len(text) != len("start")+20 { 163 + t.Errorf("concurrent apply: expected length %d, got %d (text=%q)", len("start")+20, len(text), text) 164 + } 165 + }
+7
internal/handler/handler.go
··· 825 825 } 826 826 827 827 room := h.CollaborationHub.GetOrCreateRoom(rKey) 828 + if room.IsNewRoom() { 829 + var initialText string 830 + if doc.Content != nil { 831 + initialText = doc.Content.Text.RawMarkdown 832 + } 833 + room.SeedText(initialText) 834 + } 828 835 wsClient := collaboration.NewClient(h.CollaborationHub, conn, did, name, color, rKey) 829 836 room.RegisterClient(wsClient) 830 837