177 lines
4.9 KiB
Markdown
177 lines
4.9 KiB
Markdown
# 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`.
|