fix(lsp): prevent flicker in codelens virtual text #34888

Problem:
Calling lsp.codelens.refresh() causes transient visual flicker because
codelens virtual texts are briefly replaced with "Unresolved lens ..."
before being resolved and redrawn. Since refresh() is triggered
frequently (e.g., on CursorHold or InsertLeave), this leads to redundant
and noisy virtual text updates, even when the final text hasn't changed.

Solution:
Do not update virtual text for a line if some lenses for that line are
not resolved yet.

A trade-off is that the user may temporarily see outdated virtual text.
However, that's preferable to spamming updates on every refresh.
This commit is contained in:
Jaehwang Jung
2025-07-13 08:55:58 +09:00
committed by GitHub
parent 815916b450
commit 4778a4c201

View File

@ -125,6 +125,61 @@ function M.clear(client_id, bufnr)
end end
end end
---@param lenses lsp.CodeLens[]
---@return table<integer, lsp.CodeLens[]>
local function group_lenses_by_start_line(lenses)
local lenses_by_lnum = {} ---@type table<integer, lsp.CodeLens[]>
for _, lens in pairs(lenses) do
local line_lenses = lenses_by_lnum[lens.range.start.line]
if not line_lenses then
line_lenses = {}
lenses_by_lnum[lens.range.start.line] = line_lenses
end
table.insert(line_lenses, lens)
end
return lenses_by_lnum
end
---@param bufnr integer
---@param ns integer
---@param line integer
---@param lenses lsp.CodeLens[] Lenses that start at `line`
local function display_line_lenses(bufnr, ns, line, lenses)
local chunks = {}
local num_lenses = #lenses
table.sort(lenses, function(a, b)
return a.range.start.character < b.range.start.character
end)
local has_unresolved = false
for i, lens in ipairs(lenses) do
if lens.command then
local text = lens.command.title:gsub('%s+', ' ')
table.insert(chunks, { text, 'LspCodeLens' })
if i < num_lenses then
table.insert(chunks, { ' | ', 'LspCodeLensSeparator' })
end
else
has_unresolved = true
end
end
-- If some lenses are not resolved yet, don't update the line's virtual text. Due to this, user
-- may see outdated lenses or not see already resolved lenses. However, showing outdated lenses
-- for short period of time is better than spamming user with virtual text updates.
if has_unresolved then
return
end
api.nvim_buf_clear_namespace(bufnr, ns, line, line + 1)
if #chunks > 0 then
api.nvim_buf_set_extmark(bufnr, ns, line, 0, {
virt_text = chunks,
hl_mode = 'combine',
})
end
end
--- Display the lenses using virtual text --- Display the lenses using virtual text
--- ---
---@param lenses? lsp.CodeLens[] lenses to display ---@param lenses? lsp.CodeLens[] lenses to display
@ -141,37 +196,10 @@ function M.display(lenses, bufnr, client_id)
return return
end end
local lenses_by_lnum = {} ---@type table<integer, lsp.CodeLens[]> local lenses_by_lnum = group_lenses_by_start_line(lenses)
for _, lens in pairs(lenses) do
local line_lenses = lenses_by_lnum[lens.range.start.line]
if not line_lenses then
line_lenses = {}
lenses_by_lnum[lens.range.start.line] = line_lenses
end
table.insert(line_lenses, lens)
end
local num_lines = api.nvim_buf_line_count(bufnr) local num_lines = api.nvim_buf_line_count(bufnr)
for i = 0, num_lines do for i = 0, num_lines do
local line_lenses = lenses_by_lnum[i] or {} display_line_lenses(bufnr, ns, i, lenses_by_lnum[i] or {})
api.nvim_buf_clear_namespace(bufnr, ns, i, i + 1)
local chunks = {}
local num_line_lenses = #line_lenses
table.sort(line_lenses, function(a, b)
return a.range.start.character < b.range.start.character
end)
for j, lens in ipairs(line_lenses) do
local text = (lens.command and lens.command.title or 'Unresolved lens ...'):gsub('%s+', ' ')
table.insert(chunks, { text, 'LspCodeLens' })
if j < num_line_lenses then
table.insert(chunks, { ' | ', 'LspCodeLensSeparator' })
end
end
if #chunks > 0 then
api.nvim_buf_set_extmark(bufnr, ns, i, 0, {
virt_text = chunks,
hl_mode = 'combine',
})
end
end end
end end
@ -214,40 +242,41 @@ local function resolve_lenses(lenses, bufnr, client_id, callback)
return return
end end
local function countdown() ---@param n integer
num_lens = num_lens - 1 local function countdown(n)
num_lens = num_lens - n
if num_lens == 0 then if num_lens == 0 then
callback() callback()
end end
end end
local ns = namespaces[client_id] local ns = namespaces[client_id]
local client = vim.lsp.get_client_by_id(client_id) local client = vim.lsp.get_client_by_id(client_id)
for _, lens in pairs(lenses or {}) do
if lens.command then
countdown()
else
assert(client)
client:request(ms.codeLens_resolve, lens, function(_, result)
if api.nvim_buf_is_loaded(bufnr) and result and result.command then
lens.command = result.command
-- Eager display to have some sort of incremental feedback
-- Once all lenses got resolved there will be a full redraw for all lenses
-- So that multiple lens per line are properly displayed
local num_lines = api.nvim_buf_line_count(bufnr) -- Resolve all lenses in a line, then display them.
if lens.range.start.line <= num_lines then local lenses_by_lnum = group_lenses_by_start_line(lenses)
api.nvim_buf_set_extmark( for line, line_lenses in pairs(lenses_by_lnum) do
bufnr, local num_resolved_line_lenses = 0
ns, local function display_line_countdown()
lens.range.start.line, num_resolved_line_lenses = num_resolved_line_lenses + 1
0, if num_resolved_line_lenses == #line_lenses then
{ virt_text = { { lens.command.title, 'LspCodeLens' } }, hl_mode = 'combine' } display_line_lenses(bufnr, ns, line, line_lenses)
) countdown(#line_lenses)
end
end
for _, lens in pairs(line_lenses) do
if lens.command then
display_line_countdown()
else
assert(client)
client:request(ms.codeLens_resolve, lens, function(_, result)
if api.nvim_buf_is_loaded(bufnr) and result and result.command then
lens.command = result.command
end end
end display_line_countdown()
end, bufnr)
countdown() end
end, bufnr)
end end
end end
end end
@ -268,12 +297,10 @@ function M.on_codelens(err, result, ctx)
M.save(result, bufnr, ctx.client_id) M.save(result, bufnr, ctx.client_id)
-- Eager display for any resolved (and unresolved) lenses and refresh them -- Eager display for any resolved lenses and refresh them once resolved.
-- once resolved.
M.display(result, bufnr, ctx.client_id) M.display(result, bufnr, ctx.client_id)
resolve_lenses(result, bufnr, ctx.client_id, function() resolve_lenses(result, bufnr, ctx.client_id, function()
active_refreshes[bufnr] = nil active_refreshes[bufnr] = nil
M.display(result, bufnr, ctx.client_id)
end) end)
end end