#!/usr/bin/env bun // bridge between vim (stdin/stdout json lines) and collabd (websocket) // vim spawns this process and communicates via channels import * as Y from "yjs"; const DAEMON_URL = process.env.COLLABD_URL || "ws://localhost:4040/ws"; let ws: WebSocket | null = null; let doc: Y.Doc | null = null; let text: Y.Text | null = null; let suppressLocal = false; function send(msg: object) { console.log(JSON.stringify(msg)); } // signal to vim that bridge is ready send({ type: "ready" }); function connect(roomName: string) { doc = new Y.Doc(); text = doc.getText("content"); // when remote changes come in, notify vim text.observe(() => { if (!suppressLocal) { send({ type: "content", text: text?.toString() || "" }); } }); ws = new WebSocket(DAEMON_URL); ws.onopen = () => { ws?.send(JSON.stringify({ type: "join", room: roomName })); send({ type: "connected", room: roomName }); }; ws.onmessage = (ev) => { const msg = JSON.parse(ev.data.toString()); switch (msg.type) { case "sync": case "update": { if (!doc) break; suppressLocal = true; Y.applyUpdate(doc, new Uint8Array(msg.data)); suppressLocal = false; send({ type: "content", text: text?.toString() || "" }); break; } case "peers": { send({ type: "peers", count: msg.count }); break; } } }; ws.onclose = () => { send({ type: "disconnected" }); }; ws.onerror = () => { send({ type: "error", message: "websocket error" }); }; } // compute minimal edit operations using LCS-based diff function computeDiff(oldText: string, newText: string) { // find common prefix let prefixLen = 0; while (prefixLen < oldText.length && prefixLen < newText.length && oldText[prefixLen] === newText[prefixLen]) { prefixLen++; } // find common suffix let suffixLen = 0; while (suffixLen < oldText.length - prefixLen && suffixLen < newText.length - prefixLen && oldText[oldText.length - 1 - suffixLen] === newText[newText.length - 1 - suffixLen]) { suffixLen++; } const deleteStart = prefixLen; const deleteLen = oldText.length - prefixLen - suffixLen; const insertText = newText.slice(prefixLen, newText.length - suffixLen); return { deleteStart, deleteLen, insertText }; } function setContent(newContent: string) { if (!doc || !text || !ws) return; const oldContent = text.toString(); if (oldContent === newContent) return; // compute minimal diff and apply const { deleteStart, deleteLen, insertText } = computeDiff(oldContent, newContent); const t = text; doc.transact(() => { if (deleteLen > 0) { t.delete(deleteStart, deleteLen); } if (insertText.length > 0) { t.insert(deleteStart, insertText); } }); // send update to daemon const update = Y.encodeStateAsUpdate(doc); ws.send(JSON.stringify({ type: "update", data: Array.from(update) })); } // read json lines from stdin const decoder = new TextDecoder(); for await (const chunk of Bun.stdin.stream()) { const lines = decoder.decode(chunk).trim().split("\n"); for (const line of lines) { if (!line) continue; try { const msg = JSON.parse(line); switch (msg.type) { case "connect": connect(msg.room); break; case "content": setContent(msg.text); break; case "disconnect": ws?.close(); send({ type: "disconnected" }); process.exit(0); } } catch { send({ type: "error", message: "invalid json" }); } } }