From 35044213ee530c75b917d6e21174ed37f9156342 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Sat, 26 Apr 2025 18:38:25 +0200 Subject: [PATCH 1/5] add support for launching preview via lsp --- lua/typst-preview/commands.lua | 54 ++--- lua/typst-preview/config.lua | 1 + lua/typst-preview/events/editor.lua | 13 +- lua/typst-preview/events/init.lua | 23 +- lua/typst-preview/events/server.lua | 66 +++-- lua/typst-preview/fetch.lua | 7 + lua/typst-preview/servers/base.lua | 71 ++++++ lua/typst-preview/servers/factory-lsp.lua | 124 ++++++++++ lua/typst-preview/servers/factory.lua | 60 +++-- lua/typst-preview/servers/init.lua | 278 ++++++++++++++++------ lua/typst-preview/servers/manager.lua | 97 -------- lua/typst-preview/utils.lua | 21 ++ 12 files changed, 542 insertions(+), 273 deletions(-) create mode 100644 lua/typst-preview/servers/base.lua create mode 100644 lua/typst-preview/servers/factory-lsp.lua delete mode 100644 lua/typst-preview/servers/manager.lua diff --git a/lua/typst-preview/commands.lua b/lua/typst-preview/commands.lua index 75afd6d..dd01c75 100644 --- a/lua/typst-preview/commands.lua +++ b/lua/typst-preview/commands.lua @@ -6,19 +6,12 @@ local servers = require 'typst-preview.servers' local M = {} ----Scroll all preview to cursor position. -function M.sync_with_cursor() - for _, ser in pairs(servers.get_all()) do - servers.sync_with_cursor(ser) - end -end - ---Create user commands function M.create_commands() local function preview_off() local path = utils.get_buf_path(0) - if path ~= '' and servers.remove(config.opts.get_main_file(path)) then + if path ~= '' and servers.remove{path=config.opts.get_main_file(path)} then utils.print 'Preview stopped' else utils.print 'Preview not running' @@ -38,16 +31,20 @@ function M.create_commands() ---@param mode mode? local function preview_on(mode) -- check if binaries are available and tell them to fetch first - for _, bin in pairs(fetch.bins_to_fetch()) do - if - not config.opts.dependencies_bin[bin.name] and not fetch.up_to_date(bin) - then - utils.notify( - bin.name - .. ' not found or out of date\nPlease run :TypstPreviewUpdate first!', - vim.log.levels.ERROR - ) - return + if config.opts.use_lsp then + -- FIXME: Check for tinymist to be connected + else + for _, bin in pairs(fetch.bins_to_fetch()) do + if + not config.opts.dependencies_bin[bin.name] and not fetch.up_to_date(bin) + then + utils.notify( + bin.name + .. ' not found or out of date\nPlease run :TypstPreviewUpdate first!', + vim.log.levels.ERROR + ) + return + end end end @@ -58,15 +55,13 @@ function M.create_commands() mode = mode or 'document' - local ser = servers.get(path) - if ser == nil or ser[mode] == nil then - servers.init(path, mode, function(s) - events.listen(s) - end) + local _, ser = next(servers.get{path=path, mode=mode}) + if ser == nil then + servers.init(path, mode, function(s) end) else - local s = ser[mode] + -- FIXME: try to ping the server to see whether it's really still alive print 'Opening another frontend' - utils.visit(s.link) + utils.visit(ser.link) end end @@ -93,10 +88,7 @@ function M.create_commands() if path == nil then return end - local sers = servers.get(path) - if sers ~= nil then - mode = servers.get_last_mode(path) - end + mode = servers.get_last_mode(path) end preview_on(mode) @@ -113,7 +105,7 @@ function M.create_commands() return end - if servers.get(path) ~= nil then + if next(servers.get{path=path}) ~= nil then preview_off() else preview_on(servers.get_last_mode(path)) @@ -130,7 +122,7 @@ function M.create_commands() config.set_follow_cursor(not config.get_follow_cursor()) end, {}) vim.api.nvim_create_user_command('TypstPreviewSyncCursor', function() - M.sync_with_cursor() + servers.scroll_preview() end, {}) end diff --git a/lua/typst-preview/config.lua b/lua/typst-preview/config.lua index 1028601..5a0dc5b 100644 --- a/lua/typst-preview/config.lua +++ b/lua/typst-preview/config.lua @@ -20,6 +20,7 @@ local M = { get_main_file = function(path) return path end, + use_lsp = false, }, } diff --git a/lua/typst-preview/events/editor.lua b/lua/typst-preview/events/editor.lua index d0b6a5e..b308a14 100644 --- a/lua/typst-preview/events/editor.lua +++ b/lua/typst-preview/events/editor.lua @@ -11,9 +11,8 @@ function M.register_autocmds(bufnr) local autocmds = { { event = { 'TextChanged', 'TextChangedI', 'TextChangedP', 'InsertLeave' }, - callback = function(ser, _) + callback = function(_ev) servers.update_memory_file( - ser, utils.get_buf_path(bufnr), utils.get_buf_content(bufnr) ) @@ -21,7 +20,7 @@ function M.register_autocmds(bufnr) }, { event = { 'CursorMoved' }, - callback = function(ser, _) + callback = function(_ev) if not config.get_follow_cursor() then return end @@ -29,7 +28,7 @@ function M.register_autocmds(bufnr) if last_line ~= line then -- No scroll when on the same line in insert mode last_line = line - servers.sync_with_cursor(ser) + servers.scroll_preview() end end, }, @@ -40,11 +39,7 @@ function M.register_autocmds(bufnr) { event = autocmd.event, opts = { - callback = function(ev) - for _, ser in pairs(servers.get_all()) do - autocmd.callback(ser, ev) - end - end, + callback = autocmd.callback, buffer = bufnr, }, }, diff --git a/lua/typst-preview/events/init.lua b/lua/typst-preview/events/init.lua index 14758cf..feb1f1e 100644 --- a/lua/typst-preview/events/init.lua +++ b/lua/typst-preview/events/init.lua @@ -1,3 +1,4 @@ +local config = require 'typst-preview.config' local event_server = require 'typst-preview.events.server' local utils = require 'typst-preview.utils' local editor = require 'typst-preview.events.editor' @@ -5,15 +6,21 @@ local servers = require 'typst-preview.servers' local M = {} ----Listen to Server's event ----All buffers are already watched via auto command ----@param s Server -function M.listen(s) - event_server.add_listeners(s) -end - ----Register autocmds to register autocmds for filetype +---Setup listeners for server -> editor communication, and register filetype +---autocmds that setup listeners for editor -> server communication. function M.init() + -- Listen to Server's event + servers.listen_scroll(event_server.on_editor_scroll_to) + + if config.opts.use_lsp then + -- When using tinymist via vim.lsp, we don't need to update document state + -- nvim already handles it in that case. + -- FIXME: Do we still want to use the VimLeavePre autocmd below? Or is it + -- sufficient that nvim shuts down tinymist? + return + end + + -- Register autocmds to register autocmds for filetype utils.create_autocmds('typst-preview-all-autocmds', { { event = 'FileType', diff --git a/lua/typst-preview/events/server.lua b/lua/typst-preview/events/server.lua index 29a64b8..f44806d 100644 --- a/lua/typst-preview/events/server.lua +++ b/lua/typst-preview/events/server.lua @@ -3,42 +3,40 @@ local utils = require 'typst-preview.utils' local M = {} ----Register event listener ----@param s Server -function M.add_listeners(s) - servers.listen_scroll(s, function(event) - local function editorScrollTo() - utils.debug(event.end_.row .. ' ' .. event.end_.column) - s.suppress = true - local row = event.end_.row + 1 - local max_row = vim.fn.line '$' - if row < 1 then - row = 1 - end - if row > max_row then - row = max_row - end - local column = event.end_.column - 1 - local max_column = vim.fn.col '$' - 1 - if column < 0 then - column = 0 - end - if column > max_column then - column = max_column - end - vim.api.nvim_win_set_cursor(0, { row, column }) - vim.defer_fn(function() - s.suppress = false - end, 100) +---@param jump OnEditorJumpData +function M.on_editor_scroll_to(jump) + local function editorScrollTo() + -- FIXME: What was the original reasoning for this throttling? Is it still + -- needed? How to best implement for LSP? + -- s.suppress = true + local row = jump.end_.row + 1 + local max_row = vim.fn.line '$' + if row < 1 then + row = 1 end - - if event.filepath ~= utils.get_buf_path(0) then - vim.cmd('e ' .. event.filepath) - vim.defer_fn(editorScrollTo, 100) - else - editorScrollTo() + if row > max_row then + row = max_row + end + local column = jump.end_.column - 1 + local max_column = vim.fn.col '$' - 1 + if column < 0 then + column = 0 end - end) + if column > max_column then + column = max_column + end + vim.api.nvim_win_set_cursor(0, { row, column }) + -- vim.defer_fn(function() + -- s.suppress = false + -- end, 100) + end + + if jump.filepath ~= utils.get_buf_path(0) then + vim.cmd('e ' .. jump.filepath) + vim.defer_fn(editorScrollTo, 100) + else + editorScrollTo() + end end return M diff --git a/lua/typst-preview/fetch.lua b/lua/typst-preview/fetch.lua index 64bb2fc..06c3ad9 100644 --- a/lua/typst-preview/fetch.lua +++ b/lua/typst-preview/fetch.lua @@ -198,6 +198,13 @@ end ---@param quiet boolean ---@param callback function|nil function M.fetch(quiet, callback) + if config.opts.use_lsp then + if callback ~= nil then + callback() + end + return + end + if callback == nil then callback = function() end end diff --git a/lua/typst-preview/servers/base.lua b/lua/typst-preview/servers/base.lua new file mode 100644 index 0000000..021888a --- /dev/null +++ b/lua/typst-preview/servers/base.lua @@ -0,0 +1,71 @@ +local M = {} + +-- Tinymist API types + +---@class PreviewResult +---@field staticServerAddr string|nil +---@field staticServerPort number|nil +---@field dataPlanePort number|nil +---@field isPrimary boolean|nil + +---@class JumpInfo +---@field filepath string +---@field start number[] | nil +---@field end number[] | nil + +-- Parsed JumpInfo + +---@class Location +---@field row number +---@field column number + +---@class OnEditorJumpData +---@field filepath string +---@field start Location +---@field end_ Location + +-- Server and related types + +---@alias mode 'document'|'slide' + +---@class (exact) Server +---@field path string Unsaved buffer will not be previewable. +---@field mode mode +---@field link string +---@field suppress boolean Prevent server initiated event to trigger editor initiated events. +---@field close fun() +---@field scroll_to fun(data) +---@field update_memory_file fun(data) +---@field remove_memory_file fun(data) + +function M.new_server(path, mode, link) + return { + path = path, + mode = mode, + link = link, + suppress = false, + close = function() end, + scroll_to = function() end, + update_memory_file = function() end, + remove_memory_file = function() end, + } +end + +---@class(exact) ServerFilter +---@field path? string +---@field mode? mode +---@field task_id? string + +---@param server Server +---@param filter ServerFilter +---@return boolean +function M.server_matches(server, filter) + for k, v in pairs(filter) do + if server[k] ~= v then + return false + end + end + return true +end + +return M diff --git a/lua/typst-preview/servers/factory-lsp.lua b/lua/typst-preview/servers/factory-lsp.lua new file mode 100644 index 0000000..3979e4a --- /dev/null +++ b/lua/typst-preview/servers/factory-lsp.lua @@ -0,0 +1,124 @@ +local utils = require 'typst-preview.utils' +local config = require 'typst-preview.config' +local base = require 'typst-preview.servers.base' + +-- Responsible for starting, stopping and communicating with the server +local M = {} + +---@param command string +---@param arguments +---@param callback? fun(err: string, result) +local function exec_cmd(client, command, arguments, callback) + local status, request_id = client:request( + "workspace/executeCommand", + { + command = command, + arguments = arguments, + }, + ---@type lsp.Handler + function(err, result, ctx) + if err ~= nil then + utils.debug( + "Failed to send " .. command .. " command (error in response): " + .. vim.inspect(err) + ) + return + end + + if callback ~= nil then + callback(err and err.message, result) + end + end + ) + + if not status then + utils.debug("Failed to send " .. command .. " command (error on request)") + if callback ~= nil then + callback("failed to send command", {}) + end + end +end + + + +---Spawn the server and connect to it using the websocat process +---@param path string +---@param mode mode +---@param callback fun(client: lsp.Client, task_id: string, link: string) +---Called after server spawn completes +local function spawn(path, port, mode, callback) + local client = vim.lsp.get_clients({ name = 'tinymist', buffer = 0 })[1] + if not client then + return vim.notify( + 'No Tinymist client attached to the current buffer', + vim.log.levels.ERROR + ) + end + + local task_id = utils.random_id(12) + + local args = { + '--invert-colors', + config.opts.invert_colors, + '--preview-mode', + mode, + '--no-open', + '--task-id', + task_id, + '--data-plane-host', + '127.0.0.1:' .. port, + '--root', + config.opts.get_root(path), + } + + if config.opts.extra_args ~= nil then + for _, v in ipairs(config.opts.extra_args) do + table.insert(args, v) + end + end + + table.insert(args, config.opts.get_main_file(path)) + + utils.debug("Starting preview with arguments: " .. table.concat(args, " ")) + + exec_cmd(client, 'tinymist.doStartPreview', {args}, function(err, result) + -- FIXME: Handle the AddrInUse case + -- -> actually, this currently crashed tinymist on an unwrap(), thus + -- reasonably handling this case requires an upstream change (such that + -- tinymist returns an error instead of crashing) + if err ~= nil then + -- FIXME: better communicate this to the user + utils.debug("Failed to start preview: " .. err) + return + end + + callback(client, task_id, result and result.staticServerAddr) + end) +end + +---create a new Server +---@param path string +---@param mode mode +---@param callback fun(server: Server) +function M.new(path, mode, callback) + spawn(path, config.opts.port, mode, function(client, task_id, link) + link = assert(link) + local server = base.new_server(path, mode, link) + + function server.close() + exec_cmd(client, 'tinymist.doKillPreview', {task_id}) + end + + function server.scroll_to(data) + exec_cmd(client, 'tinymist.scrollPreview', {task_id, data}) + end + + -- FIXME: Move to top-level commands + utils.visit(link) + + callback(server) + end) +end + +return M + diff --git a/lua/typst-preview/servers/factory.lua b/lua/typst-preview/servers/factory.lua index 3cfff52..912a039 100644 --- a/lua/typst-preview/servers/factory.lua +++ b/lua/typst-preview/servers/factory.lua @@ -1,3 +1,4 @@ +local base = require 'typst-preview.servers.base' local fetch = require 'typst-preview.fetch' local utils = require 'typst-preview.utils' local config = require 'typst-preview.config' @@ -174,21 +175,50 @@ end ---@param path string ---@param mode mode ---@param callback fun(server: Server) -function M.new(path, mode, callback) +---@param event_listeners { string: fun(event) } +function M.new(path, mode, callback, event_listeners) local read_buffer = '' spawn(path, config.opts.port, mode, function(close, write, read, link) - ---@type Server - local server = { - path = path, - mode = mode, - link = link, - suppress = false, - close = close, - write = write, - listenerss = {}, - } + local function exec_cmd(data) + write(vim.json.encode(data) .. '\n') + end + + local server = base.new_server(path, mode, link) + + server.close = close + server.scroll_to = exec_cmd + + ---Update a memory file. + ---@param path string + ---@param content string + function server.update_memory_file(path, content) + if server.suppress then + return + end + utils.debug('updating file: ' .. path .. ', main path: ' .. server.path) + exec_cmd { + event = 'updateMemoryFiles', + files = { + [path] = content, + }, + } + end + + ---Remove a memory file. + ---@param path string + function server.remove_memory_file(path) + if server.suppress then + return + end + utils.debug('removing file: ' .. path) + exec_cmd { + event = 'removeMemoryFiles', + files = { path }, + } + end + -- FIXME: Move JSON parsing into read() read(function(data) vim.defer_fn(function() read_buffer = read_buffer .. data @@ -200,11 +230,9 @@ function M.new(path, mode, callback) read_buffer = read_buffer:sub(s + 1, -1) s, _ = read_buffer:find '\n' - local listeners = server.listenerss[event.event] - if listeners ~= nil then - for _, listener in pairs(listeners) do - listener(event) - end + local listener = event_listeners[event.event] + if listener ~= nil then + listener(event) end end diff --git a/lua/typst-preview/servers/init.lua b/lua/typst-preview/servers/init.lua index 05a26c8..19ea137 100644 --- a/lua/typst-preview/servers/init.lua +++ b/lua/typst-preview/servers/init.lua @@ -1,101 +1,223 @@ +local base = require 'typst-preview.servers.base' +local config = require 'typst-preview.config' +local factory = require 'typst-preview.servers.factory' +local factory_lsp = require 'typst-preview.servers.factory-lsp' local utils = require 'typst-preview.utils' -local manager = require 'typst-preview.servers.manager' -local M = { - get_last_mode = manager.get_last_mode, - init = manager.init, - get = manager.get, - get_all = manager.get_all, - remove = manager.remove, - remove_all = manager.remove_all, -} - ----@alias mode 'document'|'slide' - ----@class (exact) Server ----@field path string Unsaved buffer will not be previewable. ----@field mode mode ----@field link string ----@field suppress boolean Prevent server initiated event to trigger editor initiated events. ----@field close fun() ----@field write fun(data: string) ----@field listenerss { [string]: fun(event: table)[] } - ----Update a memory file. ----@param self Server ----@param path string ----@param content string -function M.update_memory_file(self, path, content) - if self.suppress then +local M = {} + +---All running servers +---@type Server[] +local servers = {} + +---The last used preview mode by file path +---@type { [string]: mode } +local last_modes = {} + +---Whether lsp handlers have been registered +local lsp_handlers_registerd = false + +---Listeners +---@type fun(jump: OnEditorJumpData)[] +local editor_scroll_to_listeners = {} + +function M.on_editor_scroll_to(jump) + local start = jump.start + local end_ = jump['end'] + if start == nil or end_ == nil then return end - utils.debug('updating file: ' .. path .. ', main path: ' .. self.path) - self.write(vim.json.encode { - event = 'updateMemoryFiles', - files = { - [path] = content, + + utils.debug('scroll editor to line: ' .. end_[1] .. ', character: ' .. end_[2]) + + ---@type OnEditorJumpData + local parsed_jump = { + filepath = jump.filepath, + start = { + row = start[1], + column = start[2], + }, + end_ = { + row = end_[1], + column = end_[2], }, - } .. '\n') + } + + for _, listener in pairs(editor_scroll_to_listeners) do + listener(parsed_jump) + end end ----Remove a memory file. ----@param self Server +---Get last mode that init is called with ---@param path string -function M.remove_memory_file(self, path) - if self.suppress then +---@return mode? +function M.get_last_mode(path) + path = utils.abs_path(path) + return last_modes[path] +end + +---@param method string +---@param handler fun(result) +local function register_lsp_handler(method, handler) + vim.lsp.handlers[method] = function(err, result, ctx) + utils.debug( + "Received event from server: " .. ctx.method + .. ", err = " .. vim.inspect(err) + .. ", result = " .. vim.inspect(result) + ) + + if err ~= nil then + return + end + + handler(result) + end ---@type lsp.Handler +end + +local function init_lsp() + if lsp_handlers_registerd then return end - utils.debug('removing file: ' .. path) - self.write(vim.json.encode { - event = 'removeMemoryFiles', - files = { path }, - }) + + register_lsp_handler('tinymist/preview/dispose', function(result) + local task_id = result[1] + + M.remove{task_id=task_id} + end) + + register_lsp_handler('tinymist/preview/scrollSource', function(result) + ---@type JumpInfo + local jump = assert(result) + + M.on_editor_scroll_to(jump) + end) + + register_lsp_handler('tinymist/documentOutline', function(result) + -- ignore + end) end ----Scroll preview to where the cursor is. ----@param self Server -function M.sync_with_cursor(self) - if self.suppress then - return +---Init a server +---@param path string +---@param mode mode +---@param callback fun(server: Server) +function M.init(path, mode, callback) + path = utils.abs_path(path) + assert( + next(M.get{path=path, mode=mode}) == nil, + 'Server with path ' .. path .. ' and mode ' .. mode .. ' already exists.' + ) + + local function handle_new_server(server) + table.insert(servers, server) + last_modes[path] = mode + callback(server) end + + if config.opts.use_lsp then + init_lsp() + -- In the LSP case, all events are received by a global handler + factory_lsp.new(path, mode, handle_new_server) + else + -- whereas in the subprocess + websocat case, each server receives events + factory.new(path, mode, handle_new_server, { + editorScrollTo = M.on_editor_scroll_to, + }) + end +end + +---Get a server +---@param filter ServerFilter +---@return { Server[] }? +function M.get(filter) + filter.path = filter.path and utils.abs_path(filter.path) + + ---@type Server[] + local result = {} + for _, server in pairs(servers) do + if base.server_matches(server, filter) then + table.insert(result, server) + end + end + + return result +end + +---Get all servers +---@return Server[] +function M.get_all() + ---@type Server[] + local r = {} + for _, ser in pairs(servers) do + table.insert(r, ser) + end + return r +end + +---Remove all servers matching the filter and clean everything up +---@param filter ServerFilter +---@return boolean removed Whether at least one matching server existed before. +function M.remove(filter) + filter.path = filter.path and utils.abs_path(filter.path) + + local removed = false + for idx, server in pairs(servers) do + if base.server_matches(server, filter) then + servers[idx] = nil + server.close() + utils.debug( + 'Server with path ' .. server.path .. ' and mode ' .. server.mode .. ' closed.' + ) + removed = true + end + end + + if not removed then + utils.debug( + 'Attempt to remove non-existing server with filter: ' + .. vim.inspect(filter) + ) + end + + return removed +end + +---Remove all servers +function M.remove_all() + M.remove{} +end + +---@param path string +---@param content string +function M.update_memory_file(path, content) + for _, server in pairs(servers) do + if not server.suppress then + server.update_memory_file(path, content) + end + end +end + +---Scroll preview to where the cursor is. +function M.scroll_preview() local cursor = vim.api.nvim_win_get_cursor(0) local line = cursor[1] - 1 utils.debug('scroll to line: ' .. line .. ', character: ' .. cursor[2]) - self.write(vim.json.encode { - event = 'panelScrollTo', - filepath = utils.get_buf_path(0), - line = line, - character = cursor[2], - } .. '\n') -end ----Add a listener for an event from the server ----@param self Server ----@param event string ----@param listener fun(event: table) -local function add_listener(self, event, listener) - if self.listenerss[event] == nil then - self.listenerss[event] = {} + for _, server in pairs(servers) do + if not server.suppress then + server.scroll_to { + event = 'panelScrollTo', + filepath = utils.get_buf_path(0), + line = line, + character = cursor[2], + } + end end - table.insert(self.listenerss[event], listener) end ---Listen to editorScrollTo event from the server ----@param self Server ----@param listener fun(event: { filepath: string, start: { row: integer, column: integer }, end_: { row: integer, column: integer } }) -function M.listen_scroll(self, listener) - add_listener(self, 'editorScrollTo', function(event) - listener { - filepath = event.filepath, - start = { - row = event.start[1], - column = event.start[2], - }, - end_ = { - row = event['end'][1], - column = event['end'][2], - }, - } - end) +---@param listener fun(event: OnEditorJumpData) +function M.listen_scroll(listener) + table.insert(editor_scroll_to_listeners, listener) end return M diff --git a/lua/typst-preview/servers/manager.lua b/lua/typst-preview/servers/manager.lua deleted file mode 100644 index 7c57a27..0000000 --- a/lua/typst-preview/servers/manager.lua +++ /dev/null @@ -1,97 +0,0 @@ -local factory = require 'typst-preview.servers.factory' -local utils = require 'typst-preview.utils' -local M = {} - ----There can not be `servers[path]` that's empty and not nil ----@type { [string]: { [mode]: Server } } -local servers = {} - ----@type { [string]: mode } -local last_modes = {} - ----Get last mode that init is called with ----@param path string ----@return mode? -function M.get_last_mode(path) - return last_modes[path] -end - ----@param path string ----@return string -local function abs_path(path) - return vim.fn.fnamemodify(path, ':p') -end - ----Init a server ----@param path string ----@param mode mode ----@param callback fun(server: Server) -function M.init(path, mode, callback) - path = abs_path(path) - assert( - servers[path] == nil or servers[path][mode] == nil, - 'Server with path ' .. path .. ' and mode ' .. mode .. ' already exist.' - ) - factory.new(path, mode, function(server) - servers[path] = servers[path] or {} - servers[path][mode] = server - last_modes[path] = mode - callback(servers[path][mode]) - end) -end - ----Get a server ----@param path string ----@return { [mode]: Server }? -function M.get(path) - path = abs_path(path) - local ser = servers[path] - assert( - ser == nil or utils.length(ser) > 0, - 'servers[' .. path .. '] is empty and not nil.' - ) - return ser -end - ----Get all servers ----@return Server[] -function M.get_all() - ---@type Server[] - local r = {} - for _, sers in pairs(servers) do - for _, ser in pairs(sers) do - table.insert(r, ser) - end - end - return r -end - ----Remove a server and clean everything up ----@param path string ----@return boolean removed Whether a server with the path existed before. -function M.remove(path) - path = abs_path(path) - local removed = false - if servers[path] ~= nil then - for mode, _ in pairs(servers[path]) do - servers[path][mode].close() - utils.debug( - 'Server with path ' .. path .. ' and mode ' .. mode .. ' closed.' - ) - servers[path][mode] = nil - removed = true - end - assert(removed, 'servers[' .. path .. '] is empty and not nil.') - servers[path] = nil - end - return removed -end - ----Remove all servers -function M.remove_all() - for path, _ in pairs(servers) do - M.remove(path) - end -end - -return M diff --git a/lua/typst-preview/utils.lua b/lua/typst-preview/utils.lua index ff79469..f62e83c 100644 --- a/lua/typst-preview/utils.lua +++ b/lua/typst-preview/utils.lua @@ -70,6 +70,12 @@ function M.visit(link) }) end +---@param path string +---@return string +function M.abs_path(path) + return vim.fn.fnamemodify(path, ':p') +end + ---check if a file exist ---@param path string function M.file_exist(path) @@ -169,4 +175,19 @@ function M.length(table) return count end +local id_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" + +---get a random string to be used as a preview task id +---@param len number +---@return string +function M.random_id(len) + local id = "" + for _i=1,len do + local idx = math.random(1, #id_chars) + id = id .. id_chars:sub(idx, idx) + end + + return id +end + return M From 097f4d3a961958c47554a2390c651e51af94bcd9 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Sat, 26 Apr 2025 22:48:10 +0200 Subject: [PATCH 2/5] some fixes and cleanup --- lua/typst-preview/servers/base.lua | 4 +-- lua/typst-preview/servers/factory-lsp.lua | 2 ++ lua/typst-preview/servers/init.lua | 44 +++++++++++++---------- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/lua/typst-preview/servers/base.lua b/lua/typst-preview/servers/base.lua index 021888a..98d82a7 100644 --- a/lua/typst-preview/servers/base.lua +++ b/lua/typst-preview/servers/base.lua @@ -35,8 +35,8 @@ local M = {} ---@field suppress boolean Prevent server initiated event to trigger editor initiated events. ---@field close fun() ---@field scroll_to fun(data) ----@field update_memory_file fun(data) ----@field remove_memory_file fun(data) +---@field update_memory_file fun(path: string, content: string) +---@field remove_memory_file fun(data: string) function M.new_server(path, mode, link) return { diff --git a/lua/typst-preview/servers/factory-lsp.lua b/lua/typst-preview/servers/factory-lsp.lua index 3979e4a..30081fa 100644 --- a/lua/typst-preview/servers/factory-lsp.lua +++ b/lua/typst-preview/servers/factory-lsp.lua @@ -86,6 +86,8 @@ local function spawn(path, port, mode, callback) -- -> actually, this currently crashed tinymist on an unwrap(), thus -- reasonably handling this case requires an upstream change (such that -- tinymist returns an error instead of crashing) + -- cf. https://github.com/Myriad-Dreamin/tinymist/issues/1699 + -- also test with next tinymist release, the respective code has been comletely refactored if err ~= nil then -- FIXME: better communicate this to the user utils.debug("Failed to start preview: " .. err) diff --git a/lua/typst-preview/servers/init.lua b/lua/typst-preview/servers/init.lua index 19ea137..a9eacfb 100644 --- a/lua/typst-preview/servers/init.lua +++ b/lua/typst-preview/servers/init.lua @@ -126,15 +126,17 @@ function M.init(path, mode, callback) end ---Get a server ----@param filter ServerFilter ----@return { Server[] }? +---@param filter ServerFilter? +---@return Server[] function M.get(filter) - filter.path = filter.path and utils.abs_path(filter.path) + if filter ~= nil then + filter.path = filter.path and utils.abs_path(filter.path) + end ---@type Server[] local result = {} for _, server in pairs(servers) do - if base.server_matches(server, filter) then + if filter == nil or base.server_matches(server, filter) then table.insert(result, server) end end @@ -142,26 +144,17 @@ function M.get(filter) return result end ----Get all servers ----@return Server[] -function M.get_all() - ---@type Server[] - local r = {} - for _, ser in pairs(servers) do - table.insert(r, ser) - end - return r -end - ---Remove all servers matching the filter and clean everything up ----@param filter ServerFilter +---@param filter ServerFilter? ---@return boolean removed Whether at least one matching server existed before. function M.remove(filter) - filter.path = filter.path and utils.abs_path(filter.path) + if filter ~= nil then + filter.path = filter.path and utils.abs_path(filter.path) + end local removed = false for idx, server in pairs(servers) do - if base.server_matches(server, filter) then + if filter == nil or base.server_matches(server, filter) then servers[idx] = nil server.close() utils.debug( @@ -172,6 +165,10 @@ function M.remove(filter) end if not removed then + -- This is not necessarily a bug: For example, the following can happen: + -- 1. We remove a server and send tinymist.doKillPreview + -- 2. tinymist sends tinymist/preview/dispose in response + -- 3. our listener below calls remove again utils.debug( 'Attempt to remove non-existing server with filter: ' .. vim.inspect(filter) @@ -183,7 +180,7 @@ end ---Remove all servers function M.remove_all() - M.remove{} + M.remove() end ---@param path string @@ -196,6 +193,15 @@ function M.update_memory_file(path, content) end end +---@param path string +function M.remove_memory_file(path) + for _, server in pairs(servers) do + if not server.suppress then + server.remove_memory_file(path) + end + end +end + ---Scroll preview to where the cursor is. function M.scroll_preview() local cursor = vim.api.nvim_win_get_cursor(0) From ce297653e0947cc8aa32dd94b4ee601cddba8b6f Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Tue, 29 Apr 2025 13:24:16 +0200 Subject: [PATCH 3/5] remove all fetching code and refactor --- lua/typst-preview/commands.lua | 117 +++++----- lua/typst-preview/config.lua | 60 ++++- lua/typst-preview/events.lua | 90 ++++++++ lua/typst-preview/events/editor.lua | 50 ----- lua/typst-preview/events/init.lua | 49 ----- lua/typst-preview/events/server.lua | 42 ---- lua/typst-preview/fetch.lua | 253 ---------------------- lua/typst-preview/init.lua | 10 +- lua/typst-preview/manager.lua | 142 ++++++++++++ lua/typst-preview/servers/base.lua | 71 ------ lua/typst-preview/servers/factory-lsp.lua | 126 ----------- lua/typst-preview/servers/factory.lua | 249 --------------------- lua/typst-preview/servers/init.lua | 229 -------------------- lua/typst-preview/task.lua | 203 +++++++++++++++++ lua/typst-preview/utils.lua | 159 ++++---------- plugin/init.lua | 1 - 16 files changed, 593 insertions(+), 1258 deletions(-) create mode 100644 lua/typst-preview/events.lua delete mode 100644 lua/typst-preview/events/editor.lua delete mode 100644 lua/typst-preview/events/init.lua delete mode 100644 lua/typst-preview/events/server.lua delete mode 100644 lua/typst-preview/fetch.lua create mode 100644 lua/typst-preview/manager.lua delete mode 100644 lua/typst-preview/servers/base.lua delete mode 100644 lua/typst-preview/servers/factory-lsp.lua delete mode 100644 lua/typst-preview/servers/factory.lua delete mode 100644 lua/typst-preview/servers/init.lua create mode 100644 lua/typst-preview/task.lua diff --git a/lua/typst-preview/commands.lua b/lua/typst-preview/commands.lua index dd01c75..1cc44c3 100644 --- a/lua/typst-preview/commands.lua +++ b/lua/typst-preview/commands.lua @@ -1,75 +1,60 @@ +local config = require 'typst-preview.config' local events = require 'typst-preview.events' -local fetch = require 'typst-preview.fetch' +local manager = require 'typst-preview.manager' local utils = require 'typst-preview.utils' -local config = require 'typst-preview.config' -local servers = require 'typst-preview.servers' local M = {} ---Create user commands function M.create_commands() - local function preview_off() - local path = utils.get_buf_path(0) - - if path ~= '' and servers.remove{path=config.opts.get_main_file(path)} then + ---@param path string? + local function preview_off(path) + if path and manager.remove( + {path = path}, + 'user request' + ) then utils.print 'Preview stopped' else utils.print 'Preview not running' end end - local function get_path() - local path = utils.get_buf_path(0) - if path == '' then - utils.notify('Can not preview an unsaved buffer.', vim.log.levels.ERROR) - return nil - else - return config.opts.get_main_file(path) - end - end - + ---@param path string ---@param mode mode? - local function preview_on(mode) - -- check if binaries are available and tell them to fetch first - if config.opts.use_lsp then - -- FIXME: Check for tinymist to be connected - else - for _, bin in pairs(fetch.bins_to_fetch()) do - if - not config.opts.dependencies_bin[bin.name] and not fetch.up_to_date(bin) - then - utils.notify( - bin.name - .. ' not found or out of date\nPlease run :TypstPreviewUpdate first!', - vim.log.levels.ERROR - ) - return - end - end - end - - local path = get_path() - if path == nil then - return - end - + local function preview_on(path, mode) + assert(path) mode = mode or 'document' - local _, ser = next(servers.get{path=path, mode=mode}) - if ser == nil then - servers.init(path, mode, function(s) end) - else - -- FIXME: try to ping the server to see whether it's really still alive - print 'Opening another frontend' - utils.visit(ser.link) - end + events.ensure_registered() + + manager.get_or_init( + path, + mode, + function(task, is_new) + if is_new then + print 'Preview started' + else + print 'Opening another frontend' + end + utils.visit(task.link) + end + ) end - vim.api.nvim_create_user_command('TypstPreviewUpdate', function() - fetch.fetch(false) + vim.api.nvim_create_user_command('TypstPreviewUpdate', function(opts) + vim.notify( + 'TypstPreviewUpdate is deprecated', + vim.log.levels.ERROR + ) end, {}) vim.api.nvim_create_user_command('TypstPreview', function(opts) + local path = utils.get_main_file() + if path == nil then + utils.notify('Can not preview an unsaved buffer.', vim.log.levels.ERROR) + return + end + local mode if #opts.fargs == 1 then mode = opts.fargs[1] @@ -81,48 +66,54 @@ function M.create_commands() .. ' Should be one of "document" and "slide"', vim.log.levels.ERROR ) + return end else assert(#opts.fargs == 0) - local path = get_path() - if path == nil then - return - end - mode = servers.get_last_mode(path) + mode = manager.get_last_mode(path) end - preview_on(mode) + preview_on(path, mode) end, { nargs = '?', complete = function(_, _, _) return { 'document', 'slide' } end, }) - vim.api.nvim_create_user_command('TypstPreviewStop', preview_off, {}) + + vim.api.nvim_create_user_command('TypstPreviewStop', function() + local path = utils.get_main_file() + preview_off(path) + end, {}) + vim.api.nvim_create_user_command('TypstPreviewToggle', function() - local path = get_path() + local path = utils.get_main_file() if path == nil then + utils.notify('Can not preview an unsaved buffer.', vim.log.levels.ERROR) return end - if next(servers.get{path=path}) ~= nil then - preview_off() + if next(manager.get{path=path}) ~= nil then + preview_off(path) else - preview_on(servers.get_last_mode(path)) + preview_on(path, manager.get_last_mode(path)) end end, {}) vim.api.nvim_create_user_command('TypstPreviewFollowCursor', function() config.set_follow_cursor(true) end, {}) + vim.api.nvim_create_user_command('TypstPreviewNoFollowCursor', function() config.set_follow_cursor(false) end, {}) + vim.api.nvim_create_user_command('TypstPreviewFollowCursorToggle', function() config.set_follow_cursor(not config.get_follow_cursor()) end, {}) + vim.api.nvim_create_user_command('TypstPreviewSyncCursor', function() - servers.scroll_preview() + manager.scroll_preview() end, {}) end diff --git a/lua/typst-preview/config.lua b/lua/typst-preview/config.lua index 5a0dc5b..6d02cde 100644 --- a/lua/typst-preview/config.lua +++ b/lua/typst-preview/config.lua @@ -5,11 +5,6 @@ local M = { port = 0, -- tinymist will use a random port if this is 0 invert_colors = 'never', follow_cursor = true, - dependencies_bin = { - ['tinymist'] = nil, - ['websocat'] = nil, - }, - extra_args = nil, get_root = function(path_of_main_file) local root = os.getenv 'TYPST_ROOT' if root then @@ -20,11 +15,64 @@ local M = { get_main_file = function(path) return path end, - use_lsp = false, }, } +local deprecated_opts = { + 'extra_args', + 'dependencies_bin' +} +local all_opts = { + 'debug', + 'open_cmd', + 'port', + 'invert_colors', + 'follow_cursor', + 'get_root', + 'get_main_file' +} + +local function contains(table, value) + for _, v in pairs(table) do + if value == v then + return true + end + end + return false +end + function M.config(opts) + local deprecated = {} + local invalid = {} + for key, _ in pairs(opts) do + if not contains(all_opts, key) then + if contains(deprecated_opts, key) then + table.insert(deprecated, key) + else + table.insert(invalid, key) + end + opts[key] = nil + end + end + + if next(invalid) then + vim.notify( + 'typst-preview: invalid config keys: ' + .. table.concat(invalid, ', ') + .. '\n', + vim.log.levels.ERROR + ) + end + if next(deprecated) then + vim.notify( + 'typst-preview: deprecated config keys: ' + .. table.concat(deprecated, ', ') + .. '\n' + .. 'Note that the plugin has changed substantially and does not download tinymist anymore, but connects to existing language servers. Please update your configuration, cf. the documentation', + vim.log.levels.ERROR + ) + end + M.opts = vim.tbl_deep_extend('force', M.opts, opts or {}) end diff --git a/lua/typst-preview/events.lua b/lua/typst-preview/events.lua new file mode 100644 index 0000000..3d2348f --- /dev/null +++ b/lua/typst-preview/events.lua @@ -0,0 +1,90 @@ +local config = require 'typst-preview.config' +local manager = require 'typst-preview.manager' +local utils = require 'typst-preview.utils' + +---Whether lsp handlers have been registered +local lsp_handlers_registerd = false + +local M = {} + +---@param method string +---@param handler fun(result) +local function register_lsp_handler(method, handler) + vim.lsp.handlers[method] = function(err, result, ctx) + utils.debug( + "Received event from server: ", + ctx.method, + ", err = ", + err, + ", result = ", + result + ) + + if err ~= nil then + return + end + + handler(result) + end ---@type lsp.Handler +end + +function M.ensure_registered() + if lsp_handlers_registerd then + return + end + + local id = vim.api.nvim_create_augroup('typst-preview-autocmds', {}) + vim.api.nvim_create_autocmd('LspDetach', { + group = id, + callback = function(ev) + manager.remove( + { client = ev.data.client }, + 'server detached' + ) + end + }) + + vim.api.nvim_create_autocmd('CursorMoved', { + pattern = '*.typ', + callback = function(ev) + utils.debug("received CursorMoved in file ", ev.file) + if config.get_follow_cursor() then + manager.scroll_preview() + end + end + }) + + register_lsp_handler('tinymist/preview/dispose', function(result) + local task_id = result['taskId'] + + manager.remove( + { task_id = task_id }, + 'received dispose from server' + ) + end) + + -- Note that tinymist does not seem to send this event: Instead, it uses + -- 'window/showDocument', which is already handled appropriately by neovim. + -- -> there is a config option, customizedShowDocument to control which + -- notification is sent + -- cf. https://github.com/Myriad-Dreamin/tinymist/pull/1450 + -- -> requires tinymist v0.13.10 + -- This does imply that we send a panelScrollTo in response to showDocument, + -- but that doesn't seem to result in a loop, luckily + -- register_lsp_handler('tinymist/preview/scrollSource', function(result) + -- ---@type JumpInfo + -- local jump = assert(result) + -- + -- on_editor_scroll_to(jump) + -- end) + + -- Don't even register the listener: This notification is sent quite often, + -- and we don't use it right now. + -- register_lsp_handler('tinymist/documentOutline', function(result) + -- -- ignore + -- end) + + lsp_handlers_registerd = true +end + +return M diff --git a/lua/typst-preview/events/editor.lua b/lua/typst-preview/events/editor.lua deleted file mode 100644 index b308a14..0000000 --- a/lua/typst-preview/events/editor.lua +++ /dev/null @@ -1,50 +0,0 @@ -local servers = require 'typst-preview.servers' -local utils = require 'typst-preview.utils' -local config= require 'typst-preview.config' - -local M = {} - ----Register autocmds for a buffer ----@param bufnr integer -function M.register_autocmds(bufnr) - local last_line - local autocmds = { - { - event = { 'TextChanged', 'TextChangedI', 'TextChangedP', 'InsertLeave' }, - callback = function(_ev) - servers.update_memory_file( - utils.get_buf_path(bufnr), - utils.get_buf_content(bufnr) - ) - end, - }, - { - event = { 'CursorMoved' }, - callback = function(_ev) - if not config.get_follow_cursor() then - return - end - local line = vim.api.nvim_win_get_cursor(0)[1] - if last_line ~= line then - -- No scroll when on the same line in insert mode - last_line = line - servers.scroll_preview() - end - end, - }, - } - - for i, autocmd in pairs(autocmds) do - utils.create_autocmds('typst-preview-autocmds-' .. i .. '-' .. bufnr, { - { - event = autocmd.event, - opts = { - callback = autocmd.callback, - buffer = bufnr, - }, - }, - }) - end -end - -return M diff --git a/lua/typst-preview/events/init.lua b/lua/typst-preview/events/init.lua deleted file mode 100644 index feb1f1e..0000000 --- a/lua/typst-preview/events/init.lua +++ /dev/null @@ -1,49 +0,0 @@ -local config = require 'typst-preview.config' -local event_server = require 'typst-preview.events.server' -local utils = require 'typst-preview.utils' -local editor = require 'typst-preview.events.editor' -local servers = require 'typst-preview.servers' - -local M = {} - ----Setup listeners for server -> editor communication, and register filetype ----autocmds that setup listeners for editor -> server communication. -function M.init() - -- Listen to Server's event - servers.listen_scroll(event_server.on_editor_scroll_to) - - if config.opts.use_lsp then - -- When using tinymist via vim.lsp, we don't need to update document state - -- nvim already handles it in that case. - -- FIXME: Do we still want to use the VimLeavePre autocmd below? Or is it - -- sufficient that nvim shuts down tinymist? - return - end - - -- Register autocmds to register autocmds for filetype - utils.create_autocmds('typst-preview-all-autocmds', { - { - event = 'FileType', - opts = { - callback = function(ev) - editor.register_autocmds(ev.buf) - end, - pattern = 'typst', - }, - }, - { - event = 'VimLeavePre', - opts = { - callback = servers.remove_all, - }, - }, - }) - - for _, bufnr in pairs(vim.api.nvim_list_bufs()) do - if vim.bo[bufnr].filetype == 'typst' then - editor.register_autocmds(bufnr) - end - end -end - -return M diff --git a/lua/typst-preview/events/server.lua b/lua/typst-preview/events/server.lua deleted file mode 100644 index f44806d..0000000 --- a/lua/typst-preview/events/server.lua +++ /dev/null @@ -1,42 +0,0 @@ -local servers = require 'typst-preview.servers' -local utils = require 'typst-preview.utils' - -local M = {} - ----@param jump OnEditorJumpData -function M.on_editor_scroll_to(jump) - local function editorScrollTo() - -- FIXME: What was the original reasoning for this throttling? Is it still - -- needed? How to best implement for LSP? - -- s.suppress = true - local row = jump.end_.row + 1 - local max_row = vim.fn.line '$' - if row < 1 then - row = 1 - end - if row > max_row then - row = max_row - end - local column = jump.end_.column - 1 - local max_column = vim.fn.col '$' - 1 - if column < 0 then - column = 0 - end - if column > max_column then - column = max_column - end - vim.api.nvim_win_set_cursor(0, { row, column }) - -- vim.defer_fn(function() - -- s.suppress = false - -- end, 100) - end - - if jump.filepath ~= utils.get_buf_path(0) then - vim.cmd('e ' .. jump.filepath) - vim.defer_fn(editorScrollTo, 100) - else - editorScrollTo() - end -end - -return M diff --git a/lua/typst-preview/fetch.lua b/lua/typst-preview/fetch.lua deleted file mode 100644 index 06c3ad9..0000000 --- a/lua/typst-preview/fetch.lua +++ /dev/null @@ -1,253 +0,0 @@ -local utils = require 'typst-preview.utils' -local config = require 'typst-preview.config' - --- Responsible for downloading all required binary. --- Currently includes tinymist and websocat -local M = { - -- Exposing this so when platform detection fails user can manually set - -- bin_name - tinymist_bin_name = nil, - websocat_bin_name = nil, -} - -local function get_bin_name(map) - local machine - if utils.is_x64() then - machine = 'x64' - elseif utils.is_arm64() then - machine = 'arm64' - end - local os - if utils.is_macos() then - os = 'macos' - elseif utils.is_linux() then - os = 'linux' - elseif utils.is_windows() then - os = 'windows' - end - - if os == nil or machine == nil or map[os][machine] == nil then - utils.notify( - "typst-preview can't figure out your platform.\n" - .. 'Please report this bug.\n' - .. 'os_uname: ' - .. vim.inspect(vim.uv.os_uname()), - vim.log.levels.ERROR - ) - end - - return map[os][machine] -end - ----Get name of tinymist binary, this is also the name for the github asset to download. ----@return string name -function M.get_tinymist_bin_name() - if M.tinymist_bin_name == nil then - M.tinymist_bin_name = get_bin_name { - macos = { - arm64 = 'tinymist-darwin-arm64', - x64 = 'tinymist-darwin-x64', - }, - linux = { - arm64 = 'tinymist-linux-arm64', - x64 = 'tinymist-linux-x64', - }, - windows = { - arm64 = 'tinymist-win32-arm64.exe', - x64 = 'tinymist-win32-x64.exe', - }, - } - end - return M.tinymist_bin_name -end - ----Get name of websocat binary, this is also the name for the github asset to download. ----@return string name -function M.get_websocat_bin_name() - if M.websocat_bin_name == nil then - M.websocat_bin_name = get_bin_name { - macos = { - arm64 = 'websocat.aarch64-apple-darwin', - x64 = 'websocat.x86_64-apple-darwin', - }, - linux = { - arm64 = 'websocat.aarch64-unknown-linux-musl', - x64 = 'websocat.x86_64-unknown-linux-musl', - }, - windows = { - x64 = 'websocat.x86_64-pc-windows-gnu.exe', - }, - } - end - return M.websocat_bin_name -end - -local function get_path(name) - return utils.get_data_path() .. name -end - -local record_path = utils.get_data_path() .. 'version_record.txt' - ----@param bin {name: string, bin_name:string, url: string} -function M.up_to_date(bin) - local record = io.open(record_path, 'r') - if record ~= nil then - for line in record:lines() do - if bin.url == line then - return utils.file_exist(get_path(bin.bin_name)) - end - end - record:close() - end - return false -end - -local function download_bin(bin, quiet, callback) - local path = get_path(bin.bin_name) - if config.opts.dependencies_bin[bin.name] then - if not quiet then - print( - "Binary for '" - .. bin.name - .. "' has been provided in config.\n" - .. 'Please ensure manually that it is up to date.\n' - ) - end - callback(false) - return - end - if M.up_to_date(bin) then - if not quiet then - print(bin.name .. ' already up to date.' .. '\n') - end - callback(false) - return - end - - local name = bin.name - local url = bin.url - - local stdin = nil - local stdout = assert(vim.uv.new_pipe()) - local stderr = assert(vim.uv.new_pipe()) - - local function after_curl(code) - if code ~= 0 then - utils.notify( - 'Downloading ' .. name .. ' binary failed, exit code: ' .. code - ) - else - if not utils.is_windows() then - -- Set executable permission - vim.uv.spawn('chmod', { args = { '+x', path } }, function() - callback(true) - end) - else - callback(true) - end - end - end - - -- TODO add wget support - local handle, err = vim.uv.spawn('curl', { - args = { '-L', url, '--create-dirs', '--output', path, '--progress-bar' }, - stdio = { stdin, stdout, stderr }, - }, after_curl) - - if handle == nil then - utils.notify( - 'Launching curl failed: ' - .. err - .. '\nMake sure curl is installed on the system.' - ) - end - - local function read_progress(err, data) - if err then - error(err) - elseif data then - local progress = data:sub(-6, data:len()) - while progress:len() < 6 do - progress = ' ' .. progress - end - utils.print('Downloading ' .. name .. progress) - end - end - stdout:read_start(read_progress) - stderr:read_start(read_progress) -end - -function M.bins_to_fetch() - return { - { - url = 'https://github.com/Myriad-Dreamin/tinymist/releases/download/v0.13.10/' - .. M.get_tinymist_bin_name(), - bin_name = M.get_tinymist_bin_name(), - name = 'tinymist', - }, - { - url = 'https://github.com/vi/websocat/releases/download/v1.14.0/' - .. M.get_websocat_bin_name(), - bin_name = M.get_websocat_bin_name(), - name = 'websocat', - }, - } -end - ----Download all binaries and other needed artifact to utils.get_data_path() ----@param quiet boolean ----@param callback function|nil -function M.fetch(quiet, callback) - if config.opts.use_lsp then - if callback ~= nil then - callback() - end - return - end - - if callback == nil then - callback = function() end - end - local downloaded = 0 - local function finish() - if downloaded > 0 then - print( - 'All binaries required by typst-preview downloaded to ' - .. utils.get_data_path() - ) - end - local bins_to_fetch = {} - for _, bin in pairs(M.bins_to_fetch()) do - if config.opts.dependencies_bin[bin.name] == nil then - table.insert(bins_to_fetch, bin) - end - end - local record, err = io.open(record_path, 'w') - if record == nil then - error("Can't open record file!: " .. err) - end - for _, bin in pairs(bins_to_fetch) do - record:write(bin.url .. '\n') - end - record:close() - callback() - end - - local function download_bins(bins, callback_) - if #bins == 0 then - callback_() - return - end - local bin = table.remove(bins, 1) - download_bin(bin, quiet, function(did_download) - if did_download then - downloaded = downloaded + 1 - end - download_bins(bins, finish) - end) - end - - download_bins(M.bins_to_fetch(), finish) -end - -return M diff --git a/lua/typst-preview/init.lua b/lua/typst-preview/init.lua index facef16..058b540 100644 --- a/lua/typst-preview/init.lua +++ b/lua/typst-preview/init.lua @@ -1,18 +1,20 @@ local config = require 'typst-preview.config' -local fetch = require 'typst-preview.fetch' local commands = require 'typst-preview.commands' local M = { setup = function(opts) config.config(opts) - fetch.fetch(true) end, set_follow_cursor = config.set_follow_cursor, get_follow_cursor = config.get_follow_cursor, sync_with_cursor = commands.sync_with_cursor, update = function() - fetch.fetch(false) - end, + vim.notify( + 'typst-preview.update() is deprecated. ' + .. 'Note that the plugin has changed substantially and does not download tinymist anymore, but connects to the existing language server. Please update your configuration, cf. the documentation.', + vim.log.levels.ERROR + ) + end } return M diff --git a/lua/typst-preview/manager.lua b/lua/typst-preview/manager.lua new file mode 100644 index 0000000..7498b30 --- /dev/null +++ b/lua/typst-preview/manager.lua @@ -0,0 +1,142 @@ +local config = require 'typst-preview.config' +local PreviewTask = require 'typst-preview.task' +local utils = require 'typst-preview.utils' +local M = {} + +---All active preview tasks +---@type PreviewTask[] +local tasks = {} + +---The last used preview mode by file path +---@type table +local last_modes = {} + +---Get last mode that init was called with +---@param path string -- must be an absolute path +---@return mode? +function M.get_last_mode(path) + return last_modes[path] +end + +---Callback that discards crashed tasks +---@param task PreviewTask +local function on_error(task) + M.remove{task_id = task.task_id} +end + +---Return an existing preview or else init a new task +---@param path string -- must be an absolute path +---@param mode mode +---@param on_ready fun(task: PreviewTask, is_new: boolean) +function M.get_or_init( + path, + mode, + on_ready +) + for _, task in pairs(M.get{path = path, mode = mode}) do + on_ready(task, false) + return + end + + -- FIXME: must not insert the task into tasks before it is ready, since + -- otherwise, method calls to it can happen before it finished spawning + -- (or we need to delay/block method calls? Not inserting it immediately + -- could also lead to creating several tasks, only the last of which is in tasks + -- if this called in quick succession.) + local task = PreviewTask:new(path, mode) + table.insert(tasks, task) + last_modes[path] = mode + task:spawn( + on_error, + function(t) on_ready(t, true) end + ) +end + +---Get a task +--- +---If filter.path ~= nil, it must be an absolute path +---@param filter TaskFilter? +---@return PreviewTask[] +function M.get(filter) + ---@type PreviewTask[] + local result = {} + for _, task in pairs(tasks) do + if filter == nil or task:matches(filter) then + table.insert(result, task) + end + end + + return result +end + +---Close & remove all tasks matching the filter +--- +---If filter.path ~= nil, it must be an absolute path +---@param filter TaskFilter? +---@param reason string? +---@return boolean removed Whether at least one matching task existed before. +function M.remove(filter, reason) + local removed = false + for idx, task in pairs(tasks) do + if filter == nil or task:matches(filter) then + tasks[idx] = nil + task:close() + utils.debug( + 'Server with path ', + task.path, + ' and mode ', + task.mode, + ' closed', + reason and (' (' .. reason .. ')') or "" + ) + removed = true + end + end + + if not removed then + -- This is not necessarily a bug: For example, the following can happen: + -- 1. We remove a task and send tinymist.doKillPreview + -- 2. tinymist sends tinymist/preview/dispose in response + -- 3. our listener above calls remove again + utils.debug('Attempt to remove non-existing task with filter: ', filter) + end + + return removed +end + +local last_filepath +local last_line + +---Scroll all previews to the current cursor location +function M.scroll_preview() + local filepath = utils.get_buf_path() + if not filepath then + -- Not sure whether this can happen? We only call this from the '*.typ' + -- autocmd. + last_filepath = nil + last_line = nil + return + end + local cursor = vim.api.nvim_win_get_cursor(0) + local line = cursor[1] - 1 + local character = cursor[2] + + -- Don't send events if we stay on the same line + if filepath == last_filepath and line == last_line then + return + end + last_filepath = filepath + last_line = line + + utils.debug('scroll to line: ', line, ', character: ', character) + + for _, task in pairs(tasks) do + if not task.suppress then + -- FIXME: Maybe only send this to servers for which the root dir contains + -- filepath? + task:scroll_to(filepath, line, character) + end + end +end + +return M diff --git a/lua/typst-preview/servers/base.lua b/lua/typst-preview/servers/base.lua deleted file mode 100644 index 98d82a7..0000000 --- a/lua/typst-preview/servers/base.lua +++ /dev/null @@ -1,71 +0,0 @@ -local M = {} - --- Tinymist API types - ----@class PreviewResult ----@field staticServerAddr string|nil ----@field staticServerPort number|nil ----@field dataPlanePort number|nil ----@field isPrimary boolean|nil - ----@class JumpInfo ----@field filepath string ----@field start number[] | nil ----@field end number[] | nil - --- Parsed JumpInfo - ----@class Location ----@field row number ----@field column number - ----@class OnEditorJumpData ----@field filepath string ----@field start Location ----@field end_ Location - --- Server and related types - ----@alias mode 'document'|'slide' - ----@class (exact) Server ----@field path string Unsaved buffer will not be previewable. ----@field mode mode ----@field link string ----@field suppress boolean Prevent server initiated event to trigger editor initiated events. ----@field close fun() ----@field scroll_to fun(data) ----@field update_memory_file fun(path: string, content: string) ----@field remove_memory_file fun(data: string) - -function M.new_server(path, mode, link) - return { - path = path, - mode = mode, - link = link, - suppress = false, - close = function() end, - scroll_to = function() end, - update_memory_file = function() end, - remove_memory_file = function() end, - } -end - ----@class(exact) ServerFilter ----@field path? string ----@field mode? mode ----@field task_id? string - ----@param server Server ----@param filter ServerFilter ----@return boolean -function M.server_matches(server, filter) - for k, v in pairs(filter) do - if server[k] ~= v then - return false - end - end - return true -end - -return M diff --git a/lua/typst-preview/servers/factory-lsp.lua b/lua/typst-preview/servers/factory-lsp.lua deleted file mode 100644 index 30081fa..0000000 --- a/lua/typst-preview/servers/factory-lsp.lua +++ /dev/null @@ -1,126 +0,0 @@ -local utils = require 'typst-preview.utils' -local config = require 'typst-preview.config' -local base = require 'typst-preview.servers.base' - --- Responsible for starting, stopping and communicating with the server -local M = {} - ----@param command string ----@param arguments ----@param callback? fun(err: string, result) -local function exec_cmd(client, command, arguments, callback) - local status, request_id = client:request( - "workspace/executeCommand", - { - command = command, - arguments = arguments, - }, - ---@type lsp.Handler - function(err, result, ctx) - if err ~= nil then - utils.debug( - "Failed to send " .. command .. " command (error in response): " - .. vim.inspect(err) - ) - return - end - - if callback ~= nil then - callback(err and err.message, result) - end - end - ) - - if not status then - utils.debug("Failed to send " .. command .. " command (error on request)") - if callback ~= nil then - callback("failed to send command", {}) - end - end -end - - - ----Spawn the server and connect to it using the websocat process ----@param path string ----@param mode mode ----@param callback fun(client: lsp.Client, task_id: string, link: string) ----Called after server spawn completes -local function spawn(path, port, mode, callback) - local client = vim.lsp.get_clients({ name = 'tinymist', buffer = 0 })[1] - if not client then - return vim.notify( - 'No Tinymist client attached to the current buffer', - vim.log.levels.ERROR - ) - end - - local task_id = utils.random_id(12) - - local args = { - '--invert-colors', - config.opts.invert_colors, - '--preview-mode', - mode, - '--no-open', - '--task-id', - task_id, - '--data-plane-host', - '127.0.0.1:' .. port, - '--root', - config.opts.get_root(path), - } - - if config.opts.extra_args ~= nil then - for _, v in ipairs(config.opts.extra_args) do - table.insert(args, v) - end - end - - table.insert(args, config.opts.get_main_file(path)) - - utils.debug("Starting preview with arguments: " .. table.concat(args, " ")) - - exec_cmd(client, 'tinymist.doStartPreview', {args}, function(err, result) - -- FIXME: Handle the AddrInUse case - -- -> actually, this currently crashed tinymist on an unwrap(), thus - -- reasonably handling this case requires an upstream change (such that - -- tinymist returns an error instead of crashing) - -- cf. https://github.com/Myriad-Dreamin/tinymist/issues/1699 - -- also test with next tinymist release, the respective code has been comletely refactored - if err ~= nil then - -- FIXME: better communicate this to the user - utils.debug("Failed to start preview: " .. err) - return - end - - callback(client, task_id, result and result.staticServerAddr) - end) -end - ----create a new Server ----@param path string ----@param mode mode ----@param callback fun(server: Server) -function M.new(path, mode, callback) - spawn(path, config.opts.port, mode, function(client, task_id, link) - link = assert(link) - local server = base.new_server(path, mode, link) - - function server.close() - exec_cmd(client, 'tinymist.doKillPreview', {task_id}) - end - - function server.scroll_to(data) - exec_cmd(client, 'tinymist.scrollPreview', {task_id, data}) - end - - -- FIXME: Move to top-level commands - utils.visit(link) - - callback(server) - end) -end - -return M - diff --git a/lua/typst-preview/servers/factory.lua b/lua/typst-preview/servers/factory.lua deleted file mode 100644 index 912a039..0000000 --- a/lua/typst-preview/servers/factory.lua +++ /dev/null @@ -1,249 +0,0 @@ -local base = require 'typst-preview.servers.base' -local fetch = require 'typst-preview.fetch' -local utils = require 'typst-preview.utils' -local config = require 'typst-preview.config' - --- Responsible for starting, stopping and communicating with the server -local M = {} - ----Spawn the server and connect to it using the websocat process ----@param path string ----@param mode mode ----@param callback fun(close: fun(), write: fun(data: string), read: fun(on_read: fun(data: string)), link: string) ----Called after server spawn completes -local function spawn(path, port, mode, callback) - local server_stdout = assert(vim.uv.new_pipe()) - local server_stderr = assert(vim.uv.new_pipe()) - local tinymist_bin = config.opts.dependencies_bin['tinymist'] - or (utils.get_data_path() .. fetch.get_tinymist_bin_name()) - local args = { - 'preview', - '--invert-colors', - config.opts.invert_colors, - '--preview-mode', - mode, - '--no-open', - '--data-plane-host', - '127.0.0.1:0', - '--control-plane-host', - '127.0.0.1:0', - '--static-file-host', - '127.0.0.1:' .. port, - '--root', - config.opts.get_root(path), - } - - if config.opts.extra_args ~= nil then - for _, v in ipairs(config.opts.extra_args) do - table.insert(args, v) - end - end - - table.insert(args, config.opts.get_main_file(path)) - - local server_handle, _ = assert(vim.uv.spawn(tinymist_bin, { - args = args, - stdio = { nil, server_stdout, server_stderr }, - })) - utils.debug('spawning server ' .. tinymist_bin .. ' with args:') - utils.debug(vim.inspect(args)) - - -- This will be gradually filled util it's ready to be fed to callback - -- Refactor if there's a third place callback would be called. - ---@type { close: fun(), write: fun(data: string), read: fun(on_read: fun(data: string)) } | string | nil - local callback_param = nil - - local function connect(host) - local stdin = assert(vim.uv.new_pipe()) - local stdout = assert(vim.uv.new_pipe()) - local stderr = assert(vim.uv.new_pipe()) - local addr = 'ws://' .. host .. '/' - local websocat_bin = config.opts.dependencies_bin['websocat'] - or (utils.get_data_path() .. fetch.get_websocat_bin_name()) - local websocat_handle, _ = assert(vim.uv.spawn(websocat_bin, { - args = { - '-B', - '10000000', - '--origin', - 'http://localhost', - addr, - }, - stdio = { stdin, stdout, stderr }, - })) - utils.debug('websocat connecting to: ' .. addr) - stderr:read_start(function(err, data) - if err then - error(err) - elseif data then - utils.debug('websocat error: ' .. data) - end - end) - - local param = { - close = function() - websocat_handle:kill() - server_handle:kill() - end, - write = function(data) - stdin:write(data) - end, - read = function(on_read) - stdout:read_start(function(err, data) - if err then - error(err) - elseif data then - utils.debug('websocat said: ' .. data) - on_read(data) - end - end) - end, - } - if callback_param ~= nil then - assert(type(callback_param) == 'string', "callback_param isn't a string") - callback(param.close, param.write, param.read, callback_param) - else - callback_param = param - end - end - - local function find_host(server_output, prompt) - local _, s = server_output:find(prompt) - if s then - local e, _ = (server_output .. '\n'):find('\n', s + 1) - return server_output:sub(s + 1, e - 1):gsub('%s+', '') - end - end - - local function read_server(serr, server_output) - if serr then - error(serr) - end - - if not server_output then - return - end - - if server_output:find 'AddrInUse' then - print('Port ' .. port .. ' is already in use') - server_stdout:close() - server_stderr:close() - -- try again at port + 1 - vim.defer_fn(function() - spawn(path, port + 1, mode, callback) - end, 0) - end - local control_host = find_host( - server_output, - 'Control plane server listening on: ' - ) or find_host(server_output, 'Control panel server listening on: ') - local static_host = - find_host(server_output, 'Static file server listening on: ') - if control_host then - utils.debug 'Connecting to server' - connect(control_host) - end - if static_host then - utils.debug 'Setting link' - vim.defer_fn(function() - utils.visit(static_host) - if callback_param ~= nil then - assert( - type(callback_param.close) == 'function' - and type(callback_param.write) == 'function' - and type(callback_param.read) == 'function', - "callback_param's type isn't a table of functions" - ) - callback( - callback_param.close, - callback_param.write, - callback_param.read, - static_host - ) - else - callback_param = static_host - end - end, 0) - end - utils.debug(server_output) - end - - server_stdout:read_start(read_server) - server_stderr:read_start(read_server) -end - ----create a new Server ----@param path string ----@param mode mode ----@param callback fun(server: Server) ----@param event_listeners { string: fun(event) } -function M.new(path, mode, callback, event_listeners) - local read_buffer = '' - - spawn(path, config.opts.port, mode, function(close, write, read, link) - local function exec_cmd(data) - write(vim.json.encode(data) .. '\n') - end - - local server = base.new_server(path, mode, link) - - server.close = close - server.scroll_to = exec_cmd - - ---Update a memory file. - ---@param path string - ---@param content string - function server.update_memory_file(path, content) - if server.suppress then - return - end - utils.debug('updating file: ' .. path .. ', main path: ' .. server.path) - exec_cmd { - event = 'updateMemoryFiles', - files = { - [path] = content, - }, - } - end - - ---Remove a memory file. - ---@param path string - function server.remove_memory_file(path) - if server.suppress then - return - end - utils.debug('removing file: ' .. path) - exec_cmd { - event = 'removeMemoryFiles', - files = { path }, - } - end - - -- FIXME: Move JSON parsing into read() - read(function(data) - vim.defer_fn(function() - read_buffer = read_buffer .. data - local s, _ = read_buffer:find '\n' - while s ~= nil do - local event = assert(vim.json.decode(read_buffer:sub(1, s - 1))) - - -- Make sure we keep the next message in the read buffer - read_buffer = read_buffer:sub(s + 1, -1) - s, _ = read_buffer:find '\n' - - local listener = event_listeners[event.event] - if listener ~= nil then - listener(event) - end - end - - if read_buffer ~= '' then - utils.debug('Leaving for next read: ' .. read_buffer) - end - end, 0) - end) - - callback(server) - end) -end - -return M diff --git a/lua/typst-preview/servers/init.lua b/lua/typst-preview/servers/init.lua deleted file mode 100644 index a9eacfb..0000000 --- a/lua/typst-preview/servers/init.lua +++ /dev/null @@ -1,229 +0,0 @@ -local base = require 'typst-preview.servers.base' -local config = require 'typst-preview.config' -local factory = require 'typst-preview.servers.factory' -local factory_lsp = require 'typst-preview.servers.factory-lsp' -local utils = require 'typst-preview.utils' -local M = {} - ----All running servers ----@type Server[] -local servers = {} - ----The last used preview mode by file path ----@type { [string]: mode } -local last_modes = {} - ----Whether lsp handlers have been registered -local lsp_handlers_registerd = false - ----Listeners ----@type fun(jump: OnEditorJumpData)[] -local editor_scroll_to_listeners = {} - -function M.on_editor_scroll_to(jump) - local start = jump.start - local end_ = jump['end'] - if start == nil or end_ == nil then - return - end - - utils.debug('scroll editor to line: ' .. end_[1] .. ', character: ' .. end_[2]) - - ---@type OnEditorJumpData - local parsed_jump = { - filepath = jump.filepath, - start = { - row = start[1], - column = start[2], - }, - end_ = { - row = end_[1], - column = end_[2], - }, - } - - for _, listener in pairs(editor_scroll_to_listeners) do - listener(parsed_jump) - end -end - ----Get last mode that init is called with ----@param path string ----@return mode? -function M.get_last_mode(path) - path = utils.abs_path(path) - return last_modes[path] -end - ----@param method string ----@param handler fun(result) -local function register_lsp_handler(method, handler) - vim.lsp.handlers[method] = function(err, result, ctx) - utils.debug( - "Received event from server: " .. ctx.method - .. ", err = " .. vim.inspect(err) - .. ", result = " .. vim.inspect(result) - ) - - if err ~= nil then - return - end - - handler(result) - end ---@type lsp.Handler -end - -local function init_lsp() - if lsp_handlers_registerd then - return - end - - register_lsp_handler('tinymist/preview/dispose', function(result) - local task_id = result[1] - - M.remove{task_id=task_id} - end) - - register_lsp_handler('tinymist/preview/scrollSource', function(result) - ---@type JumpInfo - local jump = assert(result) - - M.on_editor_scroll_to(jump) - end) - - register_lsp_handler('tinymist/documentOutline', function(result) - -- ignore - end) -end - ----Init a server ----@param path string ----@param mode mode ----@param callback fun(server: Server) -function M.init(path, mode, callback) - path = utils.abs_path(path) - assert( - next(M.get{path=path, mode=mode}) == nil, - 'Server with path ' .. path .. ' and mode ' .. mode .. ' already exists.' - ) - - local function handle_new_server(server) - table.insert(servers, server) - last_modes[path] = mode - callback(server) - end - - if config.opts.use_lsp then - init_lsp() - -- In the LSP case, all events are received by a global handler - factory_lsp.new(path, mode, handle_new_server) - else - -- whereas in the subprocess + websocat case, each server receives events - factory.new(path, mode, handle_new_server, { - editorScrollTo = M.on_editor_scroll_to, - }) - end -end - ----Get a server ----@param filter ServerFilter? ----@return Server[] -function M.get(filter) - if filter ~= nil then - filter.path = filter.path and utils.abs_path(filter.path) - end - - ---@type Server[] - local result = {} - for _, server in pairs(servers) do - if filter == nil or base.server_matches(server, filter) then - table.insert(result, server) - end - end - - return result -end - ----Remove all servers matching the filter and clean everything up ----@param filter ServerFilter? ----@return boolean removed Whether at least one matching server existed before. -function M.remove(filter) - if filter ~= nil then - filter.path = filter.path and utils.abs_path(filter.path) - end - - local removed = false - for idx, server in pairs(servers) do - if filter == nil or base.server_matches(server, filter) then - servers[idx] = nil - server.close() - utils.debug( - 'Server with path ' .. server.path .. ' and mode ' .. server.mode .. ' closed.' - ) - removed = true - end - end - - if not removed then - -- This is not necessarily a bug: For example, the following can happen: - -- 1. We remove a server and send tinymist.doKillPreview - -- 2. tinymist sends tinymist/preview/dispose in response - -- 3. our listener below calls remove again - utils.debug( - 'Attempt to remove non-existing server with filter: ' - .. vim.inspect(filter) - ) - end - - return removed -end - ----Remove all servers -function M.remove_all() - M.remove() -end - ----@param path string ----@param content string -function M.update_memory_file(path, content) - for _, server in pairs(servers) do - if not server.suppress then - server.update_memory_file(path, content) - end - end -end - ----@param path string -function M.remove_memory_file(path) - for _, server in pairs(servers) do - if not server.suppress then - server.remove_memory_file(path) - end - end -end - ----Scroll preview to where the cursor is. -function M.scroll_preview() - local cursor = vim.api.nvim_win_get_cursor(0) - local line = cursor[1] - 1 - utils.debug('scroll to line: ' .. line .. ', character: ' .. cursor[2]) - - for _, server in pairs(servers) do - if not server.suppress then - server.scroll_to { - event = 'panelScrollTo', - filepath = utils.get_buf_path(0), - line = line, - character = cursor[2], - } - end - end -end - ----Listen to editorScrollTo event from the server ----@param listener fun(event: OnEditorJumpData) -function M.listen_scroll(listener) - table.insert(editor_scroll_to_listeners, listener) -end - -return M diff --git a/lua/typst-preview/task.lua b/lua/typst-preview/task.lua new file mode 100644 index 0000000..a48aa56 --- /dev/null +++ b/lua/typst-preview/task.lua @@ -0,0 +1,203 @@ +local utils = require 'typst-preview.utils' +local config = require 'typst-preview.config' + +-- Tinymist API types + +---@class PreviewResult +---@field staticServerAddr string|nil +---@field staticServerPort number|nil +---@field dataPlanePort number|nil +---@field isPrimary boolean|nil + +---@class JumpInfo +---@field filepath string +---@field start number[] | nil +---@field end number[] | nil + +-- PreviewTask and related types + +---@alias mode 'document'|'slide' + +-- Responsible for starting, stopping and communicating with the server +---@class (exact) PreviewTask +---@field __index table +---@field path string Unsaved buffer will not be previewable. +---@field mode mode +---@field task_id string +---@field link string? +---@field client vim.lsp.Client? +---@field suppress boolean Prevent server initiated event to trigger editor initiated events. +---@field close fun(self) +local PreviewTask = {} + +---@alias ServerEvent 'crash'|'dispose'|'link-set' + +---@param self PreviewTask +---@param command string +---@param arguments table +---@param err_callback? fun(err: string?) +---@param result_callback? fun(result: table) +function exec_cmd( + self, + command, + arguments, + err_callback, + result_callback +) + utils.debug("Sending command to server: ", command, ", arguments = ", arguments) + + local status, request_id = assert(self.client):request( + "workspace/executeCommand", + { + command = command, + arguments = arguments, + }, + ---@type lsp.Handler + function(err, result, ctx) + if err ~= nil then + utils.debug("Failed to send ", command, " command (error in response): ", err) + + if err_callback ~= nil then + err_callback(err and err.message) + end + else + if result_callback ~= nil then + result_callback(result) + end + end + end + ) + + if not status then + utils.debug("Failed to send " .. command .. " command (error on request)") + if err_callback ~= nil then + err_callback("failed to send command") + end + end +end + + + +---create a new PreviewTask +---@param path string +---@param mode mode +---@return PreviewTask +function PreviewTask:new(path, mode) + local obj = { + path = path, + mode = mode, + task_id = utils.random_id(12), + link = nil, + client = nil, + suppress = false, + } + setmetatable(obj, self) + self.__index = self + return obj +end + +---@param on_error fun(string) +---@param on_link_set fun(PreviewTask) +function PreviewTask:spawn(on_error, on_link_set) + self.client = vim.lsp.get_clients({ name = 'tinymist', buffer = 0 })[1] + if not self.client then + utils.notify( + 'No Tinymist client attached to the current buffer', + vim.log.levels.ERROR + ) + on_error(self) + return + end + + local args = { + '--invert-colors', + config.opts.invert_colors, + '--preview-mode', + self.mode, + '--no-open', + '--task-id', + self.task_id, + '--data-plane-host', + '127.0.0.1:' .. config.opts.port, + '--root', + config.opts.get_root(self.path), + } + + if config.opts.extra_args ~= nil then + for _, v in ipairs(config.opts.extra_args) do + table.insert(args, v) + end + end + + table.insert(args, config.opts.get_main_file(self.path)) + + utils.debug("Starting preview with arguments: ", args) + + exec_cmd(self, 'tinymist.doStartPreview', {args}, + function(err) + -- FIXME: Handle the AddrInUse case + -- -> actually, this currently crashes tinymist on an unwrap(), thus + -- reasonably handling this case requires an upstream change (such that + -- tinymist returns an error instead of crashing) + -- cf. https://github.com/Myriad-Dreamin/tinymist/issues/1699 + -- also test with next tinymist release, the respective code has been comletely refactored + -- FIXME: better communicate this to the user + utils.debug("Failed to start preview: ", err) + on_error(self) + end, + function(result) + self.link = (result and result.staticServerAddr) + on_link_set(self) + end +) +end + +-- FIXME: handle server events +-- delayed startup fail/crash +-- server-side close +function PreviewTask:subscribe(event, handler) +end + +function PreviewTask:close() + exec_cmd(self, 'tinymist.doKillPreview', {self.task_id}) +end + +---@param filepath string +---@param line number +---@param character number +function PreviewTask:scroll_to(filepath, line, character) + exec_cmd( + self, + 'tinymist.scrollPreview', + { + self.task_id, + { + event = 'panelScrollTo', + filepath = filepath, + line = line, + character = character, + } + } + ) +end + +---@class(exact) TaskFilter +---@field path? string -- the main file for the preview task +---@field mode? mode -- the mode that the preview uses +---@field task_id? string -- the random task id +---@field client? vim.lsp.Client -- the language server client that the preview was launched on + +---@param self PreviewTask +---@param filter TaskFilter +---@return boolean +function PreviewTask:matches(filter) + for k, v in pairs(filter) do + if self[k] ~= v then + return false + end + end + return true +end + +return PreviewTask + diff --git a/lua/typst-preview/utils.lua b/lua/typst-preview/utils.lua index f62e83c..923641b 100644 --- a/lua/typst-preview/utils.lua +++ b/lua/typst-preview/utils.lua @@ -1,73 +1,14 @@ local config = require 'typst-preview.config' local M = {} ----check if the host system is windows -function M.is_windows() - return vim.uv.os_uname().sysname == 'Windows_NT' -end - ----check if the host system is macos -function M.is_macos() - return vim.uv.os_uname().sysname == 'Darwin' -end - ----check if the host system is linux -function M.is_linux() - return vim.uv.os_uname().sysname == 'Linux' -end - ----check if the host system is wsl -function M.is_wsl() - return M.is_linux() and vim.uv.os_uname().release:lower():find 'microsoft' -end - --- Stolen from mason.nvim - ----check if the host arch is x64 -function M.is_x64() - local machine = vim.uv.os_uname().machine - return machine == 'x86_64' or machine == 'x64' -end - ----check if the host arch is arm64 -function M.is_arm64() - local machine = vim.uv.os_uname().machine - return machine == 'aarch64' - or machine == 'aarch64_be' - or machine == 'armv8b' - or machine == 'armv8l' - or machine == 'arm64' -end - -local open_cmd -if M.is_macos() then - open_cmd = 'open' -elseif M.is_windows() then - open_cmd = 'explorer.exe' -elseif M.is_wsl() then - open_cmd = '/mnt/c/Windows/explorer.exe' -else - open_cmd = 'xdg-open' -end - ---Open link in browser (platform agnostic) ---@param link string function M.visit(link) - local cmd + local opts = {} if config.opts.open_cmd ~= nil then - cmd = string.format(config.opts.open_cmd, 'http://' .. link) - else - cmd = string.format('%s http://%s', open_cmd, link) + opts.cmd = {config.opts.open_cmd } end - M.debug('Opening preview with command: ' .. cmd) - vim.fn.jobstart(cmd, { - on_stderr = function(_, data) - local msg = table.concat(data or {}, '\n') - if msg ~= '' then - print('typst-preview opening link failed: ' .. msg) - end - end, - }) + vim.ui.open('http://' .. link, opts) end ---@param path string @@ -76,24 +17,13 @@ function M.abs_path(path) return vim.fn.fnamemodify(path, ':p') end ----check if a file exist ----@param path string -function M.file_exist(path) - local f = io.open(path, 'r') - if f ~= nil then - io.close(f) - return true - else - return false - end -end - ----Get the path to store all persistent datas +---Get the path to store all persistent datas, creating it if necessary ---@return string path -function M.get_data_path() - return vim.fn.fnamemodify(vim.fn.stdpath 'data' .. '/typst-preview/', ':p') +local function get_data_path() + local path = vim.fn.fnamemodify(vim.fn.stdpath 'data' .. '/typst-preview/', ':p') + vim.fn.mkdir(path, 'p') + return path end -vim.fn.mkdir(M.get_data_path(), 'p') ---@class AutocmdOpts ---@field pattern? string[]|string @@ -104,18 +34,6 @@ vim.fn.mkdir(M.get_data_path(), 'p') ---@field once? boolean ---@field nested? boolean ----create autocmds ----@param name string ----@param autocmds { event: string[]|string, opts: AutocmdOpts }[] -function M.create_autocmds(name, autocmds) - local id = vim.api.nvim_create_augroup(name, {}) - for _, autocmd in ipairs(autocmds) do - ---@diagnostic disable-next-line: inject-field - autocmd.opts.group = id - vim.api.nvim_create_autocmd(autocmd.event, autocmd.opts) - end -end - ---print that can be called anywhere ---@param data string function M.print(data) @@ -126,18 +44,36 @@ end local file = nil ----print that only work when opts.debug = true ----@param data string -function M.debug(data) +---write debug prints to a file when opts.debug = true, else do nothing +--- +---Concatenates all arguments, converting them into a human-readable +---representation using vim.inspect. +---If an argument is a function, it will be called the corresponding part of +---the debug message lazily. +---@param ... string|number|nil|table|fun(): string +function M.debug(...) if config.opts.debug then local err if file == nil then - file, err = io.open(M.get_data_path() .. 'log.txt', "a") + file, err = io.open(get_data_path() .. 'log.txt', "a") end if file == nil then error("Can't open record file!: " .. err) end - file:write(data .. '\n') + local msg = "" + for k, v in pairs({...}) do + local part + if type(v) == "function" then + part = v() + else + part = v + end + if type(part) ~= "string" then + part = vim.inspect(part) + end + msg = msg .. part + end + file:write(msg .. '\n') end end @@ -150,29 +86,22 @@ function M.notify(data, level) end, 0) end ----get content of the buffer ----@param bufnr integer ----@return string content -function M.get_buf_content(bufnr) - return table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), '\n') -end - ----get content of the buffer ----@param bufnr integer ----@return string path +---get absolute path to the buffer's file, or nil if it is not saved +---@param bufnr? integer +---@return string? function M.get_buf_path(bufnr) - return vim.api.nvim_buf_get_name(bufnr) + local path = vim.api.nvim_buf_get_name(bufnr or 0) + if path == '' then + return nil + end + return M.abs_path(path) end ----get the length of a table ----@param table table ----@return integer -function M.length(table) - local count = 0 - for _ in pairs(table) do - count = count + 1 - end - return count +---@param bufnr? integer +---@return string? +function M.get_main_file(bufnr) + local path = M.get_buf_path(bufnr or 0) + return path and config.opts.get_main_file(path) end local id_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" diff --git a/plugin/init.lua b/plugin/init.lua index 48b8fb2..70e5e47 100644 --- a/plugin/init.lua +++ b/plugin/init.lua @@ -1,2 +1 @@ require 'typst-preview.commands'.create_commands() -require 'typst-preview.events'.init() From 0afbc95856d65808cd7088f0d3c98f8c213c39ad Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Tue, 29 Apr 2025 13:24:32 +0200 Subject: [PATCH 4/5] add a lazy.lua spec --- lazy.lua | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 lazy.lua diff --git a/lazy.lua b/lazy.lua new file mode 100644 index 0000000..0f84384 --- /dev/null +++ b/lazy.lua @@ -0,0 +1,14 @@ +return { + 'chomosuke/typst-preview.nvim', + lazy = true, + cmd = { + "TypstPreview", + "TypstPreviewStop", + "TypstPreviewToggle", + "TypstPreviewFollowCursor", + "TypstPreviewNoFollowCursor", + "TypstPreviewFollowCursorToggle", + "TypstPreviewSyncCursor", + } +} + From 234c3598ef965f8cd850bf917f71ded77626ee74 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Sat, 7 Jun 2025 10:35:42 +0200 Subject: [PATCH 5/5] restore old open_cmd behaviour Went too far when refactoring this, unintentionally removing support for %s-formatting the open_cmd string. The main goal was to use vim.ui.open() to get the default handler if open_cmd is nil. That is still the case, if an explicit open_cmd is given, existing configs should work again. --- lua/typst-preview/utils.lua | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/lua/typst-preview/utils.lua b/lua/typst-preview/utils.lua index 923641b..bb22de9 100644 --- a/lua/typst-preview/utils.lua +++ b/lua/typst-preview/utils.lua @@ -4,11 +4,29 @@ local M = {} ---Open link in browser (platform agnostic) ---@param link string function M.visit(link) - local opts = {} + link = 'http://' .. link + + local on_err = function(err) + if err ~= nil and err ~= '' then + print('typst-preview opening link failed: ' .. err) + end + end + if config.opts.open_cmd ~= nil then - opts.cmd = {config.opts.open_cmd } + local cmd = string.format(config.opts.open_cmd, link) + M.debug("Opening preview with command: " .. cmd) + -- FIXME: The docs recommend using vim.system instead + vim.fn.jobstart(cmd, { + on_stderr = function(_, data) + local msg = table.concat(data or {}, '\n') + on_err(msg) + end + }) + else + M.debug("Opening preview with default command") + local _cmd, err = vim.ui.open(link) + on_err(err) end - vim.ui.open('http://' .. link, opts) end ---@param path string