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
---@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
---
---@param lenses? lsp.CodeLens[] lenses to display
@ -141,37 +196,10 @@ function M.display(lenses, bufnr, client_id)
return
end
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
local lenses_by_lnum = group_lenses_by_start_line(lenses)
local num_lines = api.nvim_buf_line_count(bufnr)
for i = 0, num_lines do
local line_lenses = 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
display_line_lenses(bufnr, ns, i, lenses_by_lnum[i] or {})
end
end
@ -214,42 +242,43 @@ local function resolve_lenses(lenses, bufnr, client_id, callback)
return
end
local function countdown()
num_lens = num_lens - 1
---@param n integer
local function countdown(n)
num_lens = num_lens - n
if num_lens == 0 then
callback()
end
end
local ns = namespaces[client_id]
local client = vim.lsp.get_client_by_id(client_id)
for _, lens in pairs(lenses or {}) do
-- Resolve all lenses in a line, then display them.
local lenses_by_lnum = group_lenses_by_start_line(lenses)
for line, line_lenses in pairs(lenses_by_lnum) do
local num_resolved_line_lenses = 0
local function display_line_countdown()
num_resolved_line_lenses = num_resolved_line_lenses + 1
if num_resolved_line_lenses == #line_lenses then
display_line_lenses(bufnr, ns, line, line_lenses)
countdown(#line_lenses)
end
end
for _, lens in pairs(line_lenses) do
if lens.command then
countdown()
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
-- 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)
if lens.range.start.line <= num_lines then
api.nvim_buf_set_extmark(
bufnr,
ns,
lens.range.start.line,
0,
{ virt_text = { { lens.command.title, 'LspCodeLens' } }, hl_mode = 'combine' }
)
end
end
countdown()
display_line_countdown()
end, bufnr)
end
end
end
end
--- |lsp-handler| for the method `textDocument/codeLens`
@ -268,12 +297,10 @@ function M.on_codelens(err, result, ctx)
M.save(result, bufnr, ctx.client_id)
-- Eager display for any resolved (and unresolved) lenses and refresh them
-- once resolved.
-- Eager display for any resolved lenses and refresh them once resolved.
M.display(result, bufnr, ctx.client_id)
resolve_lenses(result, bufnr, ctx.client_id, function()
active_refreshes[bufnr] = nil
M.display(result, bufnr, ctx.client_id)
end)
end