diff --git a/src/async.lua b/src/async.lua new file mode 100644 index 0000000..302de62 --- /dev/null +++ b/src/async.lua @@ -0,0 +1,46 @@ +local function register_controller_handler(workspace, target, controller, handler, delay) + local async = vim.loop.new_async(function() + while true do + local success, event = pcall(controller.try_recv, controller) + if success then + if event == nil then break end + vim.schedule(function() + local ok, res = pcall(handler, event) + if not ok then + print(" !! error running callback handler: " .. res) + end + end) + else + print("error receiving: deadlocked?") + end + end + end) + -- TODO controller can't be passed to the uvloop new_thread: when sent to the new + -- Lua runtime it "loses" its methods defined with mlua, making the userdata object + -- completely useless. We can circumvent this by requiring codemp again in the new + -- thread and requesting a new reference to the same controller from che global instance + -- NOTE variables prefixed with underscore live in another Lua runtime + vim.loop.new_thread({}, function(_async, _workspace, _target, _delay) + local _codemp = require("codemp.loader")() -- TODO maybe make a native.load() idk + local _ws = _codemp.get_workspace(_workspace) + local _controller = _target ~= nil and _ws:get_buffer(_target) or _ws.cursor + while true do + local success, _ = pcall(_controller.poll, _controller) + if success then + _async:send() + if _delay ~= nil then vim.loop.sleep(_delay) end + else + local my_name = "cursor" + if _target ~= nil then + my_name = "buffer(" .. _target .. ")" + end + print(" -- stopping " .. my_name .. " controller poller") + break + end + end + end, async, workspace, target, delay) +end + +return { + handler = register_controller_handler, +} diff --git a/src/buffer.lua b/src/buffer.lua new file mode 100644 index 0000000..a1d166b --- /dev/null +++ b/src/buffer.lua @@ -0,0 +1,102 @@ +local native = require('codemp.loader')() + +local utils = require('codemp.utils') +local async = require('codemp.async') + +local id_buffer_map = {} +local buffer_id_map = {} +local ticks = {} + +local function create(workspace, name, content) + native.get_workspace(workspace):create_buffer(name, content) + print(" ++ created buffer '" .. name .. "' on " .. workspace) +end + +local function attach(workspace, name, force) + local buffer = nil + if force then + buffer = vim.api.nvim_get_current_buf() + utils.buffer.set_content(buffer, "") + else + buffer = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(buffer, 'fileformat', 'unix') + -- vim.api.nvim_buf_set_option(buffer, 'filetype', 'codemp') -- TODO get from codemp? + vim.api.nvim_buf_set_name(buffer, "codemp::" .. name) + vim.api.nvim_set_current_buf(buffer) + end + local controller = native.get_workspace(workspace):attach_buffer(name) + + -- TODO map name to uuid + + id_buffer_map[buffer] = name + buffer_id_map[name] = buffer + ticks[buffer] = 0 + + -- hook serverbound callbacks + -- TODO breaks when deleting whole lines at buffer end + vim.api.nvim_buf_attach(buffer, false, { + on_bytes = function(_, buf, tick, start_row, start_col, start_offset, old_end_row, old_end_col, old_end_byte_len, new_end_row, new_end_col, new_byte_len) + if tick <= ticks[buf] then return end + if id_buffer_map[buf] == nil then return true end -- unregister callback handler + -- local content = table.concat( + -- vim.api.nvim_buf_get_text(buf, start_row, start_col, start_row + new_end_row, start_col + new_end_col, {}), + -- '\n' + -- ) + -- print(string.format("%s %s %s %s -- '%s'", start_row, start_col, start_row + new_end_row, start_col + new_end_col, content)) + -- controller:send(start_offset, start_offset + old_end_byte_len, content) + controller:send_diff(utils.buffer.get_content(buf)) + end, + }) + + -- This is an ugly as hell fix: basically we receive all operations real fast at the start + -- so the buffer changes rapidly and it messes up tracking our delta/diff state and we + -- get borked translated TextChanges (the underlying CRDT is fine) + -- basically delay a bit so that it has time to sync and we can then get "normal slow" changes + -- vim.loop.sleep(200) -- moved inside poller thread to at least not block ui + + -- hook clientbound callbacks + async.handler(workspace, name, controller, function(event) + ticks[buffer] = vim.api.nvim_buf_get_changedtick(buffer) + local before = utils.buffer.get_content(buffer) + local after = event:apply(before) + utils.buffer.set_content(buffer, after) + -- buffer_set_content(buffer, event.content, event.first, event.last) + -- buffer_replace_content(buffer, event.first, event.last, event.content) + end, 20) -- wait 20ms before polling again because it overwhelms libuv? + + print(" ++ attached to buffer " .. name) +end + +local function detach(workspace, name) + local buffer = buffer_id_map[name] + id_buffer_map[buffer] = nil + buffer_id_map[name] = nil + native.get_workspace(workspace):disconnect_buffer(name) + vim.api.nvim_buf_delete(buffer, {}) + + print(" -- detached from buffer " .. name) +end + +local function sync(workspace) + local buffer = vim.api.nvim_get_current_buf() + local name = id_buffer_map[buffer] + if name ~= nil then + local controller = native.get_workspace(workspace):get_buffer(name) + ticks[buffer] = vim.api.nvim_buf_get_changedtick(buffer) + utils.buffer.set_content(buffer, controller.content) + print(" :: synched buffer " .. name) + else + print(" !! buffer not managed") + end +end + + +return { + create = create, + sync = sync, + attach = attach, + detach = detach, + map = id_buffer_map, + map_rev = buffer_id_map, + ticks = ticks, +} diff --git a/src/client.lua b/src/client.lua new file mode 100644 index 0000000..0a908b8 --- /dev/null +++ b/src/client.lua @@ -0,0 +1,11 @@ +local native = require('codemp.loader')() + + +local function login(username, password, workspace) + native.login(username, password, workspace) + print(" ++ logged in as '" .. username .. "' on " .. workspace) +end + +return { + login = login, +} diff --git a/src/init.lua b/src/init.lua new file mode 100644 index 0000000..3323580 --- /dev/null +++ b/src/init.lua @@ -0,0 +1,98 @@ +local native = require('codemp.loader')() -- make sure we can load the native library correctly, otherwise no point going forward + +local client = require('codemp.client') +local buffers = require('codemp.buffer') +local workspace = require('codemp.workspace') + +-- TODO nvim docs say that we should stop all threads before exiting nvim +-- but we like to live dangerously (: +vim.loop.new_thread({}, function() + vim.loop.sleep(500) -- sleep a bit leaving user config time to override logger opts + local _codemp = require('codemp.loader')() + local logger = _codemp.setup_tracing() + while true do + print(logger:recv()) + end +end) + +local active_workspace = nil -- TODO dont use a single global one!!! + +local function filter(needle, haystack) + local hints = {} + for _, opt in pairs(haystack) do + if vim.startswith(opt, needle) then + table.insert(hints, opt) + end + end + return hints +end + +vim.api.nvim_create_user_command( + "MP", + function (args) + if args.fargs[1] == "login" then + client.login(args.fargs[2], args.fargs[3], args.fargs[4]) + elseif args.fargs[1] == "create" then + if #args.fargs < 2 then error("missing buffer name") end + if active_workspace == nil then error("connect to a workspace first") end + buffers.create(active_workspace, args.fargs[2]) + elseif args.fargs[1] == "join" then + if #args.fargs < 2 then error("missing workspace name") end + active_workspace = args.fargs[2] + workspace.join(active_workspace) + elseif args.fargs[1] == "attach" then + if #args.fargs < 2 then error("missing buffer name") end + if active_workspace == nil then error("connect to a workspace first") end + buffers.attach(active_workspace, args.fargs[2], args.bang) + elseif args.fargs[1] == "sync" then + if active_workspace == nil then error("connect to a workspace first") end + buffers.sync(active_workspace) + elseif args.fargs[1] == "buffers" then + if active_workspace == nil then error("connect to a workspace first") end + workspace.buffers(active_workspace) + elseif args.fargs[1] == "users" then + if active_workspace == nil then error("connect to a workspace first") end + workspace.users(active_workspace) + elseif args.fargs[1] == "detach" then + if #args.fargs < 2 then error("missing buffer name") end + if active_workspace == nil then error("connect to a workspace first") end + buffers.detach(active_workspace, args.fargs[2]) + elseif args.fargs[1] == "leave" then + if active_workspace == nil then error("connect to a workspace first") end + workspace.leave() + active_workspace = nil + end + end, + { + nargs = "+", + complete = function (lead, cmd, _pos) + local args = vim.split(cmd, " ", { plain = true, trimempty = false }) + local stage = #args + if stage == 1 then + return { "MP" } + elseif stage == 2 then + return filter(lead, {'login', 'create', 'join', 'attach', 'sync', 'buffers', 'users', 'detach', 'leave'}) + elseif stage == 3 then + if args[#args-1] == 'attach' or args[#args-1] == 'detach' then + if active_workspace ~= nil then + local ws = native.get_workspace(active_workspace) + if ws ~= nil then + return filter(lead, ws.filetree) + end + end + end + + return {} + end + end, + } +) + +return { + native = native, + client = client, + buffers = buffers, + workspace = workspace, + utils = require('codemp.utils'), + async = require('codemp.async'), +} diff --git a/src/loader.lua b/src/loader.lua new file mode 100644 index 0000000..2fb3f54 --- /dev/null +++ b/src/loader.lua @@ -0,0 +1,6 @@ +-- TODO check for platform? download it? check for updates? +local function loader() + return require("libcodemp") +end + +return loader diff --git a/src/utils.lua b/src/utils.lua new file mode 100644 index 0000000..96ac014 --- /dev/null +++ b/src/utils.lua @@ -0,0 +1,125 @@ +local function split_without_trim(str, sep) + local res = vim.fn.split(str, sep) + if str:sub(1,1) == "\n" then + table.insert(res, 1, '') + end + if str:sub(-1) == "\n" then + table.insert(res, '') + end + return res +end + +local function order_tuples(x) -- TODO send help... + if x[1][1] < x[2][1] then + return { { x[1][1], x[1][2] }, { x[2][1], x[2][2] } } + elseif x[1][1] > x[2][1] then + return { { x[2][1], x[2][2] }, { x[1][1], x[1][2] } } + elseif x[1][2] < x[2][2] then + return { { x[1][1], x[1][2] }, { x[2][1], x[2][2] } } + else + return { { x[2][1], x[2][2] }, { x[1][1], x[1][2] } } + end +end + +local function cursor_position() + local mode = vim.api.nvim_get_mode().mode + if mode == "v" then + local _, ls, cs = unpack(vim.fn.getpos('v')) + local _, le, ce = unpack(vim.fn.getpos('.')) + return order_tuples({ { ls-1, cs-1 }, { le-1, ce } }) + elseif mode == "V" then + local _, ls, _ = unpack(vim.fn.getpos('v')) + local _, le, _ = unpack(vim.fn.getpos('.')) + if le > ls then + local ce = vim.fn.strlen(vim.fn.getline(le)) + return { { ls-1, 0 }, { le-1, ce } } + else + local ce = vim.fn.strlen(vim.fn.getline(ls)) + return { { le-1, 0 }, { ls-1, ce } } + end + else + local win = vim.api.nvim_get_current_win() + local cur = vim.api.nvim_win_get_cursor(win) + return order_tuples({ { cur[1]-1, cur[2] }, { cur[1]-1, cur[2]+1 } }) + end +end + +local function buffer_get_content(buf) + if buf == nil then + buf = vim.api.nvim_get_current_buf() + end + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + return table.concat(lines, '\n') +end + +local function buffer_set_content(buf, content, first, last) + if first == nil and last == nil then + local lines = split_without_trim(content, "\n") + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + else + -- TODO there is no api equivalent of byte2line afaik! + -- this is theoretically a big deal because we can only + -- operate on current buffer, but in practice we may only + -- really change the currently active buffer so this + -- may not matter + local first_row = vim.fn.byte2line(first + 1) - 1 + local first_col = first - vim.api.nvim_buf_get_offset(buf, first_row) + local last_row = vim.fn.byte2line(last + 1) - 1 + local last_col = last - vim.api.nvim_buf_get_offset(buf, last_row) + vim.api.nvim_buf_set_text( + buf, first_row, first_col, last_row, last_col, + split_without_trim(content, "\n") + ) + end +end + +local function buffer_replace_content(buffer, first, last, content) + -- TODO send help it works but why is lost knowledge + local start_row = vim.fn.byte2line(first + 1) - 1 + if start_row < 0 then start_row = 0 end + local start_row_byte = vim.fn.line2byte(start_row + 1) - 1 + if start_row_byte < 0 then start_row_byte = 0 end + local end_row = vim.fn.byte2line(last + 1) - 1 + if end_row < 0 then end_row = 0 end + local end_row_byte = vim.fn.line2byte(end_row + 1) - 1 + if end_row_byte < 0 then end_row_byte = 0 end + vim.api.nvim_buf_set_text( + buffer, + start_row, + first - start_row_byte, + end_row, + last - end_row_byte, + vim.fn.split(content, '\n', true) + ) +end + +local function multiline_highlight(buf, ns, group, start, fini) + for i=start.row,fini.row do + if i == start.row and i == fini.row then + local fini_col = fini.col + if start.col == fini.col then fini_col = fini_col + 1 end + vim.api.nvim_buf_add_highlight(buf, ns, group, i, start.col, fini_col) + elseif i == start.row then + vim.api.nvim_buf_add_highlight(buf, ns, group, i, start.col, -1) + elseif i == fini.row then + vim.api.nvim_buf_add_highlight(buf, ns, group, i, 0, fini.col) + else + vim.api.nvim_buf_add_highlight(buf, ns, group, i, 0, -1) + end + end +end + + +return { + split_without_trim = split_without_trim, + order_tuples = order_tuples, + multiline_highlight = multiline_highlight, + cursor = { + position = cursor_position, + }, + buffer = { + get_content = buffer_get_content, + set_content = buffer_set_content, + replace_content = buffer_replace_content, + }, +} diff --git a/src/workspace.lua b/src/workspace.lua new file mode 100644 index 0000000..de2642a --- /dev/null +++ b/src/workspace.lua @@ -0,0 +1,86 @@ +local native = require('codemp.loader')() + +local utils = require('codemp.utils') +local buffers = require('codemp.buffer') +local async = require('codemp.async') + +local user_hl = {} +local available_colors = { -- TODO these are definitely not portable! + "ErrorMsg", + "WarningMsg", + "MatchParen", + "SpecialMode", + "CmpItemKindFunction", + "CmpItemKindValue", + "CmpItemKindInterface", +} + +local function register_cursor_callback(controller, workspace, buffer) + vim.api.nvim_create_autocmd({"CursorMoved", "CursorMovedI", "ModeChanged"}, { + group = vim.api.nvim_create_augroup("codemp-workspace-" .. workspace, { clear = true }), + callback = function (_) + local cur = utils.cursor.position() + local buf = buffer or vim.api.nvim_get_current_buf() + if buffers.map[buf] ~= nil then + controller:send(buffers.map[buf], cur[1][1], cur[1][2], cur[2][1], cur[2][2]) + end + end + }) +end + +local function register_cursor_handler(controller, workspace) + async.handler(workspace, nil, controller, function(event) + if user_hl[event.user] == nil then + user_hl[event.user] = { + ns = vim.api.nvim_create_namespace("codemp-cursor-" .. event.user), + hi = available_colors[ math.random( #available_colors ) ], + } + end + local buffer = buffers.map_rev[event.position.buffer] + if buffer ~= nil then + vim.api.nvim_buf_clear_namespace(buffer, user_hl[event.user].ns, 0, -1) + utils.multiline_highlight( + buffer, + user_hl[event.user].ns, + user_hl[event.user].hi, + event.position.start, + event.position.finish + ) + end + end, 20) +end + +local function join(workspace) + local controller = native.join_workspace(workspace) + register_cursor_callback(controller, workspace) + register_cursor_handler(controller, workspace) + print(" ++ joined workspace " .. workspace) +end + +local function leave() + native.leave_workspace() + print(" -- left workspace") +end + +local function list_users(workspace) + local workspace = native.get_workspace(workspace) + for _, buffer in ipairs(workspace.users) do + print(" - " .. buffer) + end +end + +local function list_buffers(workspace) + local workspace = native.get_workspace(workspace) + for _, buffer in ipairs(workspace.filetree) do + print(" > " .. buffer) + end +end + +return { + join = join, + leave = leave, + buffers = list_buffers, + users = list_users, + map = user_hl, + colors = available_colors, +}