fix(ui): only internal messages are unsafe #37462

Problem:  Fast context for msg_show event inhibits vim.ui_attach from
          displaying a stream of messages from a single command.

Solution: Remove fast context from msg_show events emitted as a result
          of explicit API/command calls. The fast context was originally
          introduced to prevent issues with internal messages.
This commit is contained in:
luukvbaal
2026-01-27 00:18:51 +01:00
committed by GitHub
parent e9d03b92b6
commit d30d91f3a4
12 changed files with 215 additions and 140 deletions

View File

@@ -38,17 +38,19 @@ ext.msg = require('vim._extui.messages')
ext.cmd = require('vim._extui.cmdline')
local M = {}
local function ui_callback(event, ...)
local function ui_callback(redraw_msg, event, ...)
local handler = ext.msg[event] or ext.cmd[event]
ext.check_targets()
handler(...)
-- Cmdline mode and non-empty showcmd requires an immediate redraw.
if ext.cmd[event] or event == 'msg_showcmd' and select(1, ...)[1] then
-- Cmdline mode, non-fast message and non-empty showcmd require an immediate redraw.
if ext.cmd[event] or redraw_msg or (event == 'msg_showcmd' and select(1, ...)[1]) then
ext.redrawing = true
api.nvim__redraw({
flush = handler ~= ext.cmd.cmdline_hide or nil,
cursor = handler == ext.cmd[event] and true or nil,
win = handler == ext.cmd[event] and ext.wins.cmd or nil,
})
ext.redrawing = false
end
end
local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
@@ -86,10 +88,11 @@ function M.enable(opts)
if not (ext.msg[event] or ext.cmd[event]) then
return
end
if vim.in_fast_event() then
scheduled_ui_callback(event, ...)
-- Ensure cmdline is placed after a scheduled message in block mode.
if vim.in_fast_event() or (event == 'cmdline_show' and ext.cmd.srow > 0) then
scheduled_ui_callback(false, event, ...)
else
ui_callback(event, ...)
ui_callback(event == 'msg_show', event, ...)
end
return true
end)

View File

@@ -37,6 +37,13 @@ local M = {
on_dialog_key = 0, -- vim.on_key namespace for paging in the dialog window.
}
-- An external redraw indicates the start of a new batch of messages in the cmdline.
api.nvim_set_decoration_provider(ext.ns, {
on_start = function()
M.cmd.count = ext.redrawing and M.cmd.count or 0
end,
})
function M.msg:close()
self.width, M.virt.msg[M.virt.idx.dupe][1] = 1, nil
M.prev_msg = ext.cfg.msg.target == 'msg' and '' or M.prev_msg
@@ -182,53 +189,46 @@ end
-- We need to keep track of the current message column to be able to
-- append or overwrite messages for :echon or carriage returns.
local col, will_full, hlopts = 0, false, { undo_restore = false, invalidate = true, priority = 1 }
local col, hlopts = 0, { undo_restore = false, invalidate = true, priority = 1 }
--- Move messages to cmdline or pager to show in full.
local function msg_to_full(src)
if will_full then
return
end
will_full, M.prev_msg = true, ''
vim.schedule(function()
-- Copy and clear message from src to enlarged cmdline that is dismissed by any
-- key press, or append to pager in case that is already open (not hidden).
local hidden = api.nvim_win_get_config(ext.wins.pager).hide
local tar = hidden and 'cmd' or 'pager'
if tar ~= src then
local srow = hidden and 0 or api.nvim_buf_line_count(ext.bufs.pager)
local marks = api.nvim_buf_get_extmarks(ext.bufs[src], -1, 0, -1, { details = true })
local lines = api.nvim_buf_get_lines(ext.bufs[src], 0, -1, false)
api.nvim_buf_set_lines(ext.bufs[src], 0, -1, false, {})
api.nvim_buf_set_lines(ext.bufs[tar], srow, -1, false, lines)
for _, mark in ipairs(marks) do
hlopts.end_col, hlopts.hl_group = mark[4].end_col, mark[4].hl_group
api.nvim_buf_set_extmark(ext.bufs[tar], ext.ns, srow + mark[2], mark[3], hlopts)
end
if tar == 'cmd' and ext.cmd.highlighter then
ext.cmd.highlighter.active[ext.bufs.cmd] = nil
elseif tar == 'pager' then
api.nvim_command('norm! G')
end
M.virt.msg[M.virt.idx.spill][1] = nil
else
for _, id in pairs(M.virt.ids) do
api.nvim_buf_del_extmark(ext.bufs.cmd, ext.ns, id)
end
-- Copy and clear message from src to enlarged cmdline that is dismissed by any
-- key press, or append to pager in case that is already open (not hidden).
local hidden = api.nvim_win_get_config(ext.wins.pager).hide
local tar = hidden and 'cmd' or 'pager'
if tar ~= src then
local srow = hidden and 0 or api.nvim_buf_line_count(ext.bufs.pager)
local marks = api.nvim_buf_get_extmarks(ext.bufs[src], -1, 0, -1, { details = true })
local lines = api.nvim_buf_get_lines(ext.bufs[src], 0, -1, false)
api.nvim_buf_set_lines(ext.bufs[src], 0, -1, false, {})
api.nvim_buf_set_lines(ext.bufs[tar], srow, -1, false, lines)
for _, mark in ipairs(marks) do
hlopts.end_col, hlopts.hl_group = mark[4].end_col, mark[4].hl_group
api.nvim_buf_set_extmark(ext.bufs[tar], ext.ns, srow + mark[2], mark[3], hlopts)
end
M.msg:close()
M.set_pos(tar)
M[src].count, col, will_full = 0, 0, false
end)
if tar == 'cmd' and ext.cmd.highlighter then
ext.cmd.highlighter.active[ext.bufs.cmd] = nil
elseif tar == 'pager' then
api.nvim_command('norm! G')
end
M[src].count = 0
M.virt.msg[M.virt.idx.spill][1] = nil
else
for _, id in pairs(M.virt.ids) do
api.nvim_buf_del_extmark(ext.bufs.cmd, ext.ns, id)
end
end
M.set_pos(tar)
end
local reset_timer ---@type uv.uv_timer_t?
---@param tar 'cmd'|'dialog'|'msg'|'pager'
---@param content MsgContent
---@param replace_last boolean
---@param append boolean
function M.show_msg(tar, content, replace_last, append)
local msg, restart, cr, dupe, count = '', false, false, 0, 0
append = append and col > 0
if M[tar] then -- tar == 'cmd'|'msg'
if tar == ext.cfg.msg.target then
@@ -244,7 +244,7 @@ function M.show_msg(tar, content, replace_last, append)
count = M[tar].count + ((restart or msg == '\n') and 0 or 1)
-- Ensure cmdline is clear when writing the first message.
if tar == 'cmd' and not will_full and dupe == 0 and M.cmd.count == 0 and ext.cmd.srow == 0 then
if tar == 'cmd' and dupe == 0 and M.cmd.count == 0 and ext.cmd.srow == 0 then
api.nvim_buf_set_lines(ext.bufs.cmd, 0, -1, false, {})
end
end
@@ -257,7 +257,7 @@ function M.show_msg(tar, content, replace_last, append)
local line_count = api.nvim_buf_line_count(ext.bufs[tar])
---@type integer Start row after last line in the target buffer, unless
---this is the first message, or in case of a repeated or replaced message.
local row = M[tar] and count <= 1 and not will_full and (tar == 'cmd' and ext.cmd.erow or 0)
local row = M[tar] and count <= 1 and ext.cmd.srow == 0 and 0
or line_count - ((replace_last or restart or cr or append) and 1 or 0)
local curline = (cr or append) and api.nvim_buf_get_lines(ext.bufs[tar], row, row + 1, false)[1]
local start_row, width = row, M.msg.width
@@ -298,7 +298,6 @@ function M.show_msg(tar, content, replace_last, append)
local texth = api.nvim_win_text_height(ext.wins.msg, { start_row = start_row })
if texth.all > math.ceil(o.lines * 0.5) then
msg_to_full(tar)
return
end
M.set_pos('msg')
@@ -314,10 +313,8 @@ function M.show_msg(tar, content, replace_last, append)
fn.clearmatches(ext.wins.cmd) -- Clear matchparen highlights.
if ext.cmd.srow > 0 then
-- In block mode the cmdheight is already dynamic, so just print the full message
-- regardless of height. Spoof cmdline_show to put cmdline below message.
ext.cmd.srow = ext.cmd.srow + 1 + row - start_row
ext.cmd.cmdline_show({}, 0, ':', '', ext.cmd.indent, 0, 0)
api.nvim__redraw({ flush = true, cursor = true, win = ext.wins.cmd })
-- regardless of height. Put cmdline below message.
ext.cmd.srow = row + 1
else
api.nvim_win_set_cursor(ext.wins.cmd, { 1, 0 }) -- ensure first line is visible
if ext.cmd.highlighter then
@@ -331,7 +328,6 @@ function M.show_msg(tar, content, replace_last, append)
if texth.all > ext.cmdheight then
msg_to_full(tar)
return
end
end
end
@@ -345,10 +341,10 @@ function M.show_msg(tar, content, replace_last, append)
end
-- Reset message state the next event loop iteration.
if start_row == 0 or ext.cmd.srow > 0 then
vim.schedule(function()
col, M.cmd.count = 0, 0
end)
if not reset_timer and (col > 0 or M.cmd.count > 0) then
reset_timer = vim.defer_fn(function()
reset_timer, col, M.cmd.count = nil, 0, 0
end, 0)
end
end
@@ -452,9 +448,14 @@ function M.msg_history_show(entries, prev_cmd)
return
end
if prev_cmd then
M.msg_clear() -- Showing output of previous command, clear in case still visible.
if cmd_on_key then
-- Dismiss a still open full message cmd window.
api.nvim_feedkeys(vim.keycode('<CR>'), 'n', false)
elseif prev_cmd then
-- Showing output of previous command, clear in case still visible.
M.msg_clear()
end
api.nvim_buf_set_lines(ext.bufs.pager, 0, -1, false, {})
for i, entry in ipairs(entries) do
M.show_msg('pager', entry[2], i == 1, entry[3])
@@ -483,7 +484,7 @@ function M.set_pos(type)
}
api.nvim_win_set_config(win, config)
if type == 'cmd' then
if type == 'cmd' and not cmd_on_key then
-- Temporarily showing a full message in the cmdline, until next key press.
local save_spill = M.virt.msg[M.virt.idx.spill][1]
local spill = texth.all > height and (' [+%d]'):format(texth.all - height)

View File

@@ -5,6 +5,7 @@ local M = {
ns = api.nvim_create_namespace('nvim._ext_ui'),
augroup = api.nvim_create_augroup('nvim._ext_ui', {}),
cmdheight = vim.o.cmdheight, -- 'cmdheight' option value set by user.
redrawing = false, -- True when redrawing to display UI event.
wins = { cmd = -1, dialog = -1, msg = -1, pager = -1 },
bufs = { cmd = -1, dialog = -1, msg = -1, pager = -1 },
cfg = {

View File

@@ -227,8 +227,9 @@ function vim.wait(time, callback, interval, fast_only) end
--- {callback} receives event name plus additional parameters. See |ui-popupmenu|
--- and the sections below for event format for respective events.
---
--- Callbacks for `msg_show` events are executed in |api-fast| context; showing
--- the message should be scheduled.
--- Callbacks for `msg_show` events originating from internal messages (as
--- opposed to events from commands or API calls) are executed in |api-fast|
--- context; showing the message needs to be scheduled.
---
--- Excessive errors inside the callback will result in forced detachment.
---

View File

@@ -383,10 +383,6 @@ local function progress_report(len)
-- percent=0 omits the reporting of percentage, so use 1% instead
-- progress.percent = progress.percent == 0 and 1 or progress.percent
progress.id = vim.api.nvim_echo({ { fmt:format(...) } }, false, progress)
-- extui/ui2 shows all messages at once after the healthchecks are finished.
-- This 1ms wait ensures the messages are shown separately
vim.wait(1)
vim.cmd.redraw()
end
end