From 86630712990293864e2dab3b781a36f194409169 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Tue, 27 Jan 2026 21:48:25 -0500 Subject: [PATCH] Add adapter implementation guide --- docs/ADAPTERS.md | 177 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/ADAPTERS.md diff --git a/docs/ADAPTERS.md b/docs/ADAPTERS.md new file mode 100644 index 0000000..bf2cf51 --- /dev/null +++ b/docs/ADAPTERS.md @@ -0,0 +1,177 @@ +# 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`.