if !has('vim9script') || v:version < 900 finish endif vim9script # collab.vim - collaborative editing adapter for collabd # requires: bun, collabd daemon running hlset([{ name: 'PeerCursor', ctermbg: 'yellow', ctermfg: 'black', guibg: 'yellow', guifg: 'black' }]) var bridge_job: job = null_job var bridge_channel: channel = null_channel var connected = false var ready = false var room = "" var suppressing = false var peer_match_ids: dict = {} # path to bridge script (adjust as needed) const bridge_script = expand(':p:h') .. '/bridge.ts' def Send(msg: dict): void if bridge_channel != null_channel ch_sendraw(bridge_channel, json_encode(msg) .. "\n") endif enddef def OnOutput(ch: channel, msg: string): void if msg->empty() return endif var data: dict try data = json_decode(msg) catch return endtry if data.type == 'ready' ready = true elseif data.type == 'connected' connected = true echom $'[collab] connected to room: {data.room}' elseif data.type == 'disconnected' connected = false echom '[collab] disconnected' elseif data.type == 'content' ApplyRemoteContent(data.text) elseif data.type == 'peers' echom $'[collab] peers: {data.count}' elseif data.type == 'cursor' ShowPeerCursor(data.data) elseif data.type == 'error' echoerr '[collab] ' .. data.message endif enddef def ApplyRemoteContent(content: string): void if suppressing return endif suppressing = true var lines = content->split("\n", true) var view = winsaveview() silent! :%delete _ setline(1, lines) winrestview(view) suppressing = false enddef def SendBuffer(): void if !connected || suppressing return endif var lines = getline(1, '$') var content = lines->join("\n") Send({type: 'content', text: content}) enddef def SendCursor(): void if !connected return endif const pos = getpos('.') # pos is [bufnum, line, col, off] - line/col are 1-indexed Send({type: 'cursor', line: pos[1] - 1, col: pos[2] - 1}) enddef def ShowPeerCursor(data: dict): void const client_id = string(data.clientId) # Clear previous highlight for this peer if has_key(peer_match_ids, client_id) silent! matchdelete(peer_match_ids[client_id]) endif # Highlight the cursor position (line, col are 0-indexed from bridge) const line_nr = data.line + 1 const col_nr = data.col + 1 # Create a 1-char highlight at cursor position const pattern = $'\%{line_nr}l\%{col_nr}c.' peer_match_ids[client_id] = matchadd('PeerCursor', pattern, 10) enddef def ClearPeerCursors(): void for id in values(peer_match_ids) silent! matchdelete(id) endfor peer_match_ids = {} enddef export def Connect(room_name: string): void if bridge_job != null_job Disconnect() endif room = room_name ready = false bridge_job = job_start(['bun', 'run', bridge_script], { mode: 'nl', out_cb: OnOutput, err_io: 'null' }) bridge_channel = job_getchannel(bridge_job) # wait for bridge ready signal var timeout = 50 while !ready && timeout > 0 sleep 10m timeout -= 1 endwhile if !ready echoerr '[collab] bridge failed to start' Disconnect() return endif Send({type: 'connect', room: room_name}) # set up autocmds to send changes autocmd_add([ { group: 'CollabVim', event: ['TextChanged', 'TextChangedI'], pattern: '*', cmd: 'SendBuffer()' }, { group: 'CollabVim', event: ['CursorMoved', 'CursorMovedI'], bufnr: bufnr(), cmd: 'SendCursor()' } ]) enddef export def Disconnect(): void if bridge_job != null_job Send({type: 'disconnect'}) job_stop(bridge_job) bridge_job = null_job bridge_channel = null_channel endif ClearPeerCursors() connected = false ready = false room = "" autocmd_delete([{group: 'CollabVim'}]) echom '[collab] disconnected' enddef export def Status(): void if connected echom $'[collab] connected to room: {room}' else echom '[collab] not connected' endif enddef # commands command! -nargs=1 CollabJoin call Connect() command! CollabLeave call Disconnect() command! CollabStatus call Status()