···1818}
19192020type ClientMessage struct {
2121- Type string `json:"type"`
2222- RKey string `json:"rkey,omitempty"`
2323- DID string `json:"did,omitempty"`
2121+ Type string `json:"type"`
2222+ RKey string `json:"rkey,omitempty"`
2323+ DID string `json:"did,omitempty"`
2424+ // Deltas is the new plural field — a single edit message may carry
2525+ // multiple operations (e.g. one per CodeMirror ChangeDesc).
2626+ Deltas []Operation `json:"deltas,omitempty"`
2727+ // Delta is the legacy singular field. Kept for backward compatibility.
2428 Delta json.RawMessage `json:"delta,omitempty"`
2529 Cursor *CursorPos `json:"cursor,omitempty"`
2630 Comment *CommentMsg `json:"comment,omitempty"`
3131+}
3232+3333+// Operations returns the ops from this message, preferring Deltas over the
3434+// legacy singular Delta field.
3535+func (m *ClientMessage) Operations() []Operation {
3636+ if len(m.Deltas) > 0 {
3737+ return m.Deltas
3838+ }
3939+ if len(m.Delta) > 0 {
4040+ var op Operation
4141+ if err := json.Unmarshal(m.Delta, &op); err == nil {
4242+ return []Operation{op}
4343+ }
4444+ }
4545+ return nil
2746}
28472948type CursorPos struct {
···8210183102 switch msg.Type {
84103 case "edit":
8585- var op Operation
8686- if err := json.Unmarshal(msg.Delta, &op); err != nil {
8787- log.Printf("Failed to parse delta from %s: %v", c.DID, err)
104104+ ops := msg.Operations()
105105+ if len(ops) == 0 {
88106 continue
89107 }
9090- op.Author = c.DID
91108 room := c.hub.GetRoom(c.roomKey)
9292- if room != nil {
9393- room.ApplyEdit(op, c)
109109+ if room == nil {
110110+ continue
111111+ }
112112+ for i := range ops {
113113+ ops[i].Author = c.DID
94114 }
115115+ room.ApplyEdits(ops, c)
95116 case "ping":
96117 pong, _ := json.Marshal(map[string]string{"type": "pong"})
97118 c.send <- pong
+50
internal/collaboration/client_test.go
···11+package collaboration
22+33+import (
44+ "encoding/json"
55+ "testing"
66+)
77+88+func TestClientMessage_ParseDeltas_Multiple(t *testing.T) {
99+ raw := `{"type":"edit","deltas":[{"from":0,"to":5,"insert":"hello"},{"from":10,"to":10,"insert":"!"}]}`
1010+ var msg ClientMessage
1111+ if err := json.Unmarshal([]byte(raw), &msg); err != nil {
1212+ t.Fatalf("unmarshal: %v", err)
1313+ }
1414+ if len(msg.Deltas) != 2 {
1515+ t.Fatalf("expected 2 deltas, got %d", len(msg.Deltas))
1616+ }
1717+ if msg.Deltas[0].From != 0 || msg.Deltas[0].To != 5 || msg.Deltas[0].Insert != "hello" {
1818+ t.Errorf("delta[0]: %+v", msg.Deltas[0])
1919+ }
2020+ if msg.Deltas[1].From != 10 || msg.Deltas[1].Insert != "!" {
2121+ t.Errorf("delta[1]: %+v", msg.Deltas[1])
2222+ }
2323+}
2424+2525+func TestClientMessage_ParseDeltas_SingleFallback(t *testing.T) {
2626+ // Old wire format: singular "delta" field — must still work.
2727+ raw := `{"type":"edit","delta":{"from":3,"to":7,"insert":"xyz"}}`
2828+ var msg ClientMessage
2929+ if err := json.Unmarshal([]byte(raw), &msg); err != nil {
3030+ t.Fatalf("unmarshal: %v", err)
3131+ }
3232+ ops := msg.Operations()
3333+ if len(ops) != 1 {
3434+ t.Fatalf("expected 1 op from fallback, got %d", len(ops))
3535+ }
3636+ if ops[0].From != 3 || ops[0].To != 7 || ops[0].Insert != "xyz" {
3737+ t.Errorf("op: %+v", ops[0])
3838+ }
3939+}
4040+4141+func TestClientMessage_ParseDeltas_EmptyDeltas(t *testing.T) {
4242+ raw := `{"type":"edit","deltas":[]}`
4343+ var msg ClientMessage
4444+ if err := json.Unmarshal([]byte(raw), &msg); err != nil {
4545+ t.Fatalf("unmarshal: %v", err)
4646+ }
4747+ if len(msg.Operations()) != 0 {
4848+ t.Errorf("expected 0 ops for empty deltas")
4949+ }
5050+}
+39
internal/collaboration/hub.go
···126126 r.BroadcastExcept(data, sender)
127127}
128128129129+// ApplyEdits applies a sequence of operations in order and broadcasts one
130130+// combined message to all other clients. Each op is applied to the text
131131+// resulting from the previous op, so positions in each op must be relative
132132+// to the document state after all prior ops in the same batch have been
133133+// applied — which is exactly what CodeMirror's iterChanges produces (fromA/toA
134134+// are positions in the pre-change document, already adjusted for prior changes
135135+// within the same transaction by CodeMirror itself).
136136+func (r *Room) ApplyEdits(ops []Operation, sender *Client) {
137137+ if len(ops) == 0 {
138138+ return
139139+ }
140140+141141+ for i := range ops {
142142+ r.ot.ApplyWithVersion(ops[i])
143143+ }
144144+145145+ // Include the full document text so receivers can detect and recover from
146146+ // divergence without a reconnect.
147147+ finalText := r.ot.GetText()
148148+ type editsMsg struct {
149149+ Type string `json:"type"`
150150+ Deltas []Operation `json:"deltas"`
151151+ Author string `json:"author"`
152152+ Content string `json:"content"`
153153+ }
154154+ msg := editsMsg{
155155+ Type: "edit",
156156+ Deltas: ops,
157157+ Author: sender.DID,
158158+ Content: finalText,
159159+ }
160160+ data, err := json.Marshal(msg)
161161+ if err != nil {
162162+ log.Printf("ApplyEdits: marshal: %v", err)
163163+ return
164164+ }
165165+ r.BroadcastExcept(data, sender)
166166+}
167167+129168func (r *Room) RegisterClient(client *Client) {
130169 r.register <- client
131170}