feat: compile notifications and long-running provider feedback (#55)

* feat(compiler): add compile start/complete notifications

Problem: No user-facing feedback when compilation starts or finishes.
Long-running compilers like pandoc with `--embed-resources` leave the
user staring at nothing for 15+ seconds.

Solution: Notify "compiling..." at compile start and "compilation
complete" on success. The initial `toggle` call uses a combined
"compiling with <name>..." message to avoid stacking two notifications.

* refactor(presets): use `--katex` instead of `--embed-resources --mathml`

Problem: `--embed-resources` with `--mathml` caused pandoc to inline all
assets at compile time, adding ~15s per save for KaTeX-heavy documents.

Solution: Default to `--katex`, which inserts a CDN `<script>` tag and
defers rendering to the browser. Users can opt into `--embed-resources`
or `--mathml` via `extra_args`.

* docs(presets): rewrite math rendering section for `--katex` default

* refactor(compiler): simplify notification flow, add failure notify

Problem: `compile()` used an `opts.silent` escape hatch so `toggle()`
could suppress duplicate notifications. Compilation failures had no
user-facing notification.

Solution: Remove `opts.silent` — `compile()` unconditionally notifies
on start, success, and failure. `toggle()` no longer emits its own
message.

* feat(compiler): add per-recompile notifications for long-running providers

Problem: long-running providers like `typst watch` had no per-recompile
feedback — the process stays alive and individual success/failure was
never reported.

Solution: add a persistent `output_watcher` fs_event that fires
"compilation complete" on every output mtime bump, and track
`has_errors` on `BufState` so stderr diagnostics trigger a one-shot
"compilation failed" notification. `diagnostic.set()` now returns the
diagnostic count to support this flow.

* ci: format

* chore: remove testing files
This commit is contained in:
Barrett Ruth 2026-03-06 14:58:10 -05:00 committed by GitHub
parent aeea1bd8fa
commit 39406c559c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 217 additions and 44 deletions

View file

@ -12,6 +12,8 @@ local log = require('preview.log')
---@field viewer? table
---@field viewer_open? boolean
---@field open_watcher? uv.uv_fs_event_t
---@field output_watcher? uv.uv_fs_event_t
---@field has_errors? boolean
---@field debounce? uv.uv_timer_t
---@field bwp_autocmd? integer
---@field unload_autocmd? integer
@ -41,6 +43,17 @@ local function stop_open_watcher(bufnr)
s.open_watcher = nil
end
---@param bufnr integer
local function stop_output_watcher(bufnr)
local s = state[bufnr]
if not (s and s.output_watcher) then
return
end
s.output_watcher:stop()
s.output_watcher:close()
s.output_watcher = nil
end
---@param bufnr integer
local function close_viewer(bufnr)
local s = state[bufnr]
@ -56,16 +69,17 @@ end
---@param provider preview.ProviderConfig
---@param ctx preview.Context
---@param output string
---@return integer
local function handle_errors(bufnr, name, provider, ctx, output)
local errors_mode = provider.errors
if errors_mode == nil then
errors_mode = 'diagnostic'
end
if not (provider.error_parser and errors_mode) then
return
return 0
end
if errors_mode == 'diagnostic' then
diagnostic.set(bufnr, name, provider.error_parser, output, ctx)
return diagnostic.set(bufnr, name, provider.error_parser, output, ctx)
elseif errors_mode == 'quickfix' then
local ok, diags = pcall(provider.error_parser, output, ctx)
if ok and diags and #diags > 0 then
@ -83,8 +97,10 @@ local function handle_errors(bufnr, name, provider, ctx, output)
local win = vim.fn.win_getid()
vim.cmd.cwindow()
vim.fn.win_gotoid(win)
return #diags
end
end
return 0
end
---@param bufnr integer
@ -169,6 +185,7 @@ local function stop_watching(bufnr, s)
s.watching = false
M.stop(bufnr)
stop_open_watcher(bufnr)
stop_output_watcher(bufnr)
close_viewer(bufnr)
s.viewer_open = nil
if s.bwp_autocmd then
@ -250,7 +267,11 @@ function M.compile(bufnr, name, provider, ctx, opts)
return
end
stderr_acc[#stderr_acc + 1] = data
handle_errors(bufnr, name, provider, ctx, table.concat(stderr_acc))
local count = handle_errors(bufnr, name, provider, ctx, table.concat(stderr_acc))
if count > 0 and not s.has_errors then
s.has_errors = true
vim.notify('[preview.nvim]: compilation failed', vim.log.levels.ERROR)
end
end),
},
vim.schedule_wrap(function(result)
@ -263,6 +284,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
end
if result.code ~= 0 then
log.dbg('long-running process failed for buffer %d (exit code %d)', bufnr, result.code)
vim.notify('[preview.nvim]: compilation failed', vim.log.levels.ERROR)
handle_errors(bufnr, name, provider, ctx, (result.stdout or '') .. (result.stderr or ''))
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileFailed',
@ -325,10 +347,54 @@ function M.compile(bufnr, name, provider, ctx, opts)
end
end
if output_file ~= '' then
local out_dir = vim.fn.fnamemodify(output_file, ':h')
local out_name = vim.fn.fnamemodify(output_file, ':t')
stop_output_watcher(bufnr)
local ow = vim.uv.new_fs_event()
if ow then
s.output_watcher = ow
local last_mtime = 0
local stat = vim.uv.fs_stat(output_file)
if stat then
last_mtime = stat.mtime.sec
end
ow:start(
out_dir,
{},
vim.schedule_wrap(function(err, filename, _events)
if err or vim.fn.fnamemodify(filename or '', ':t') ~= out_name then
return
end
if not vim.api.nvim_buf_is_valid(bufnr) then
stop_output_watcher(bufnr)
return
end
local new_stat = vim.uv.fs_stat(output_file)
if not (new_stat and new_stat.mtime.sec > last_mtime) then
return
end
last_mtime = new_stat.mtime.sec
log.dbg('output updated for buffer %d', bufnr)
vim.notify('[preview.nvim]: compilation complete', vim.log.levels.INFO)
stderr_acc = {}
s.has_errors = false
clear_errors(bufnr, provider)
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileSuccess',
data = { bufnr = bufnr, provider = name, output = output_file },
})
end)
)
end
end
s.process = obj
s.provider = name
s.is_reload = true
s.has_errors = false
vim.notify('[preview.nvim]: compiling...', vim.log.levels.INFO)
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileStarted',
data = { bufnr = bufnr, provider = name },
@ -360,6 +426,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
end
if result.code == 0 then
log.dbg('compilation succeeded for buffer %d', bufnr)
vim.notify('[preview.nvim]: compilation complete', vim.log.levels.INFO)
clear_errors(bufnr, provider)
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileSuccess',
@ -385,6 +452,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
end
else
log.dbg('compilation failed for buffer %d (exit code %d)', bufnr, result.code)
vim.notify('[preview.nvim]: compilation failed', vim.log.levels.ERROR)
handle_errors(bufnr, name, provider, ctx, (result.stdout or '') .. (result.stderr or ''))
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileFailed',
@ -403,6 +471,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
s.provider = name
s.is_reload = false
vim.notify('[preview.nvim]: compiling...', vim.log.levels.INFO)
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileStarted',
data = { bufnr = bufnr, provider = name },
@ -415,6 +484,7 @@ function M.stop(bufnr)
if not s then
return
end
stop_output_watcher(bufnr)
local obj = s.process
if not obj then
return
@ -480,6 +550,7 @@ function M.toggle(bufnr, name, provider, ctx_builder)
callback = function()
M.stop(bufnr)
stop_open_watcher(bufnr)
stop_output_watcher(bufnr)
if not provider.detach then
close_viewer(bufnr)
end
@ -512,7 +583,6 @@ function M.toggle(bufnr, name, provider, ctx_builder)
log.dbg('watching buffer %d with provider "%s"', bufnr, name)
end
vim.notify('[preview.nvim]: watching with "' .. name .. '"', vim.log.levels.INFO)
M.compile(bufnr, name, provider, ctx_builder(bufnr))
end