# Writing Editor Adapters for collabd This guide explains how to write an adapter that lets any editor participate in collaborative editing sessions. ## Architecture ``` ┌─────────────┐ json/channel ┌─────────────┐ websocket ┌─────────────┐ │ Editor │ ◄──────────────────► │ Bridge │ ◄────────────────► │ Daemon │ │ (plugin) │ │ (bun) │ │ :4040 │ └─────────────┘ └─────────────┘ └─────────────┘ ``` The **bridge** is a Bun process that: - Manages the Yjs document (CRDT state) - Translates editor buffer changes to Yjs operations - Translates remote Yjs updates to buffer content - Speaks a simple JSON protocol with the editor plugin The **plugin** is editor-specific code that: - Hooks buffer change events - Sends buffer content to bridge - Applies remote content from bridge - Optionally displays peer cursors ## Why a Bridge? Most editors can't embed Yjs directly: - Vim: No npm/node, limited async - Helix: Rust-only plugins - Kakoune: Shell-based scripting The bridge isolates CRDT complexity. Plugins stay simple. ## Protocol: Plugin ↔ Bridge Messages are newline-delimited JSON. ### Plugin → Bridge ```json {"type": "connect", "room": "myroom"} {"type": "disconnect"} {"type": "content", "text": "full buffer contents"} {"type": "cursor", "line": 5, "col": 10} ``` ### Bridge → Plugin ```json {"type": "ready"} {"type": "connected", "room": "myroom"} {"type": "disconnected"} {"type": "content", "text": "full buffer contents"} {"type": "peers", "count": 2} {"type": "cursor", "data": {"clientId": 123, "line": 5, "col": 10, "name": "peer-123"}} {"type": "error", "message": "connection failed"} ``` ## Implementing a Plugin ### 1. Spawn the Bridge ``` bun run adapters/vim/bridge.ts ``` Or use your own bridge (see next section). Communication via stdin/stdout with JSON lines. ### 2. Wait for Ready Bridge sends `{"type": "ready"}` when it starts. Then send connect: ```json {"type": "connect", "room": "myroom"} ``` ### 3. Handle Content Updates When you receive `{"type": "content", "text": "..."}`: 1. Save cursor position 2. Replace buffer contents with received text 3. Restore cursor position (clamped to valid range) **Critical:** Do NOT trigger your change handler when applying remote content, or you'll create an infinite loop. ### 4. Send Local Changes When user edits the buffer: 1. Get full buffer text 2. Send `{"type": "content", "text": "full text"}` The bridge handles diffing - just send the whole buffer. ### 5. Cursor Sync (Optional) On cursor move: ```json {"type": "cursor", "line": 5, "col": 10} ``` On receiving cursor: ```json {"type": "cursor", "data": {"line": 5, "col": 10, "name": "peer"}} ``` Render a highlight/virtual text at that position. ## Implementing a Custom Bridge If you need a bridge in a different language (e.g., Rust for Helix), implement the same protocol. Key responsibilities: 1. **Connect to daemon** at `ws://localhost:4040/ws` 2. **Manage Y.Doc** - create on connect, apply updates 3. **Diff buffer changes** - when receiving content from plugin: ``` old_text = current Y.Text content new_text = received buffer content diff = compute_diff(old, new) apply diff as Y.Text operations ``` 4. **Forward updates** - when Y.Doc changes from remote: ``` send full text to plugin as {"type": "content", ...} ``` 5. **Handle awareness** - forward cursor positions both ways ### Yjs Libraries by Language - **JavaScript/TypeScript:** `yjs` (npm) - **Rust:** `yrs` (crates.io) - Yjs port - **Python:** `pycrdt` (pypi) - **Go:** `yjs-go` (experimental) ## Reference: Vim Adapter See `adapters/vim/` for a complete example: - `collab.vim` - Vim9script plugin - `bridge.ts` - TypeScript bridge process The vim plugin: 1. Spawns bridge as job 2. Communicates via channels (vim's async IPC) 3. Sets TextChanged autocmd to detect edits 4. Applies remote content with `:delete` + `setline()` ## Testing Your Adapter 1. Start daemon: `just dev` 2. Open editor A, join room "test" 3. Open editor B (vim works), join room "test" 4. Type in one - should appear in other within 100ms 5. Type in both simultaneously - should converge ## Common Pitfalls **Feedback loops:** Applying remote content triggers your change handler, which sends it back. Use a flag to suppress during remote apply. **Cursor jitter:** Cursor moves during remote apply. Save/restore position. **Large files:** Sending full buffer on every keystroke is fine for <1MB. For larger files, consider debouncing or incremental updates. **Encoding:** Always UTF-8. Line endings should be `\n`.