commit 98e3a571ddf4bfa02fbd98f547b75f5335b3766d
parent 2728b4efe03056a14c2c6b09f8186f9244e8cf58
Author: Evgeni Chasnovski <evgeni.chasnovski@gmail.com>
Date: Sun, 5 Oct 2025 19:17:52 +0300
feat(pack): add code actions in confirmation buffer
Problem: No way to granularly operate on plugins when inside
confirmation buffer.
Solution: Implement code actions for in-process LSP that act on "plugin
at cursor":
- Update (if has updates).
- Skip updating (if has updates).
- Delete.
Activate via default `gra` or `vim.lsp.buf.code_action()`.
Diffstat:
4 files changed, 262 insertions(+), 27 deletions(-)
diff --git a/runtime/doc/pack.txt b/runtime/doc/pack.txt
@@ -402,6 +402,9 @@ update({names}, {opts}) *vim.pack.update()*
• 'textDocument/hover' (`K` via |lsp-defaults| or
|vim.lsp.buf.hover()|) - show more information at cursor. Like
details of particular pending change or newer tag.
+ • 'textDocument/codeAction' (`gra` via |lsp-defaults| or
+ |vim.lsp.buf.code_action()|) - show code actions available for
+ "plugin at cursor". Like "delete", "update", or "skip updating".
Execute |:write| to confirm update, execute |:quit| to discard the
update.
• If `true`, make updates right away.
diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua
@@ -897,7 +897,7 @@ local function feedback_log(plug_list)
end
--- @param lines string[]
---- @param on_finish fun()
+--- @param on_finish fun(bufnr: integer)
local function show_confirm_buf(lines, on_finish)
-- Show buffer in a separate tabpage
local bufnr = api.nvim_create_buf(true, true)
@@ -917,7 +917,7 @@ local function show_confirm_buf(lines, on_finish)
-- Define action on accepting confirm
local function finish()
- on_finish()
+ on_finish(bufnr)
delete_buffer()
end
-- - Use `nested` to allow other events (useful for statuslines)
@@ -945,6 +945,28 @@ local function show_confirm_buf(lines, on_finish)
vim.lsp.buf_attach_client(bufnr, require('vim.pack._lsp').client_id)
end
+--- Get map of plugin names that need update based on confirmation buffer
+--- content: all plugin sections present in "# Update" section.
+--- @param bufnr integer
+--- @return table<string,boolean>
+local function get_update_map(bufnr)
+ local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
+ --- @type table<string,boolean>, boolean
+ local res, is_in_update = {}, false
+ for _, l in ipairs(lines) do
+ local name = l:match('^## (.+)$')
+ if name and is_in_update then
+ res[name] = true
+ end
+
+ local group = l:match('^# (%S+)')
+ if group then
+ is_in_update = group == 'Update'
+ end
+ end
+ return res
+end
+
--- @class vim.pack.keyset.update
--- @inlinedoc
--- @field force? boolean Whether to skip confirmation and make updates immediately. Default `false`.
@@ -966,6 +988,9 @@ end
--- - 'textDocument/hover' (`K` via |lsp-defaults| or |vim.lsp.buf.hover()|) -
--- show more information at cursor. Like details of particular pending
--- change or newer tag.
+--- - 'textDocument/codeAction' (`gra` via |lsp-defaults| or |vim.lsp.buf.code_action()|) -
+--- show code actions available for "plugin at cursor". Like "delete", "update",
+--- or "skip updating".
---
--- Execute |:write| to confirm update, execute |:quit| to discard the update.
--- - If `true`, make updates right away.
@@ -996,11 +1021,11 @@ function M.update(names, opts)
--- @param p vim.pack.Plug
local function do_update(p)
-- Fetch
- -- Using '--tags --force' means conflicting tags will be synced with remote
- git_cmd(
- { 'fetch', '--quiet', '--tags', '--force', '--recurse-submodules=yes', 'origin' },
- p.path
- )
+ if not opts._offline then
+ -- Using '--tags --force' means conflicting tags will be synced with remote
+ local args = { 'fetch', '--quiet', '--tags', '--force', '--recurse-submodules=yes', 'origin' }
+ git_cmd(args, p.path)
+ end
-- Compute change info: changelog if any, new tags if nothing to update
infer_update_details(p)
@@ -1010,7 +1035,8 @@ function M.update(names, opts)
checkout(p, timestamp, true)
end
end
- local progress_title = opts.force and 'Updating' or 'Downloading updates'
+ local progress_title = opts.force and (opts._offline and 'Applying updates' or 'Updating')
+ or 'Downloading updates'
run_list(plug_list, do_update, progress_title)
if opts.force then
@@ -1021,17 +1047,18 @@ function M.update(names, opts)
-- Show report in new buffer in separate tabpage
local lines = compute_feedback_lines(plug_list, false)
- show_confirm_buf(lines, function()
- -- TODO(echasnovski): Allow to not update all plugins via LSP code actions
- --- @param p vim.pack.Plug
- local plugs_to_checkout = vim.tbl_filter(function(p)
- return p.info.err == '' and p.info.sha_head ~= p.info.sha_target
- end, plug_list)
- if #plugs_to_checkout == 0 then
+ show_confirm_buf(lines, function(bufnr)
+ local to_update = get_update_map(bufnr)
+ if not next(to_update) then
notify('Nothing to update', 'WARN')
return
end
+ --- @param p vim.pack.Plug
+ local plugs_to_checkout = vim.tbl_filter(function(p)
+ return to_update[p.spec.name]
+ end, plug_list)
+
local timestamp2 = get_timestamp()
--- @async
--- @param p vim.pack.Plug
diff --git a/runtime/lua/vim/pack/_lsp.lua b/runtime/lua/vim/pack/_lsp.lua
@@ -3,6 +3,7 @@ local M = {}
local capabilities = {
codeActionProvider = true,
documentSymbolProvider = true,
+ executeCommandProvider = { commands = { 'delete_plugin', 'update_plugin', 'skip_update_plugin' } },
hoverProvider = true,
}
--- @type table<string,function>
@@ -22,6 +23,48 @@ local get_confirm_bufnr = function(uri)
return tonumber(uri:match('^nvim%-pack://(%d+)/confirm%-update$'))
end
+local group_header_pattern = '^# (%S+)'
+local plugin_header_pattern = '^## (.+)$'
+
+--- @return { group: string?, name: string?, from: integer?, to: integer? }
+local get_plug_data_at_lnum = function(bufnr, lnum)
+ local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
+ --- @type string, string, integer, integer
+ local group, name, from, to
+ for i = lnum, 1, -1 do
+ group = group or lines[i]:match(group_header_pattern) --[[@as string]]
+ -- If group is found earlier than name - `lnum` is for group header line
+ -- If later - proper group header line.
+ if group then
+ break
+ end
+ name = name or lines[i]:match(plugin_header_pattern) --[[@as string]]
+ from = (not from and name) and i or from --[[@as integer]]
+ end
+ if not (group and name and from) then
+ return {}
+ end
+ --- @cast group string
+ --- @cast from integer
+
+ for i = lnum + 1, #lines do
+ if lines[i]:match(group_header_pattern) or lines[i]:match(plugin_header_pattern) then
+ -- Do not include blank line before next section
+ to = i - 2
+ break
+ end
+ end
+ to = to or #lines
+
+ if not (from <= lnum and lnum <= to) then
+ return {}
+ end
+ return { group = group, name = name, from = from, to = to }
+end
+
+--- @alias vim.pack.lsp.Position { line: integer, character: integer }
+--- @alias vim.pack.lsp.Range { start: vim.pack.lsp.Position, end: vim.pack.lsp.Position }
+
--- @param params { textDocument: { uri: string } }
--- @param callback function
methods['textDocument/documentSymbol'] = function(params, callback)
@@ -30,8 +73,6 @@ methods['textDocument/documentSymbol'] = function(params, callback)
return callback(nil, {})
end
- --- @alias vim.pack.lsp.Position { line: integer, character: integer }
- --- @alias vim.pack.lsp.Range { start: vim.pack.lsp.Position, end: vim.pack.lsp.Position }
--- @alias vim.pack.lsp.Symbol {
--- name: string,
--- kind: number,
@@ -69,28 +110,81 @@ methods['textDocument/documentSymbol'] = function(params, callback)
end
local group_kind = vim.lsp.protocol.SymbolKind.Namespace
- local symbols = parse_headers('^# (%S+)', 0, #lines - 1, group_kind)
+ local symbols = parse_headers(group_header_pattern, 0, #lines - 1, group_kind)
local plug_kind = vim.lsp.protocol.SymbolKind.Module
for _, group in ipairs(symbols) do
local start_line, end_line = group.range.start.line, group.range['end'].line
- group.children = parse_headers('^## (.+)$', start_line, end_line, plug_kind)
+ group.children = parse_headers(plugin_header_pattern, start_line, end_line, plug_kind)
end
return callback(nil, symbols)
end
+--- @alias vim.pack.lsp.CodeActionContext { diagnostics: table, only: table?, triggerKind: integer? }
+
+--- @param params { textDocument: { uri: string }, range: vim.pack.lsp.Range, context: vim.pack.lsp.CodeActionContext }
--- @param callback function
-methods['textDocument/codeAction'] = function(_, callback)
- -- TODO(echasnovski)
- -- Suggested actions for "plugin under cursor":
- -- - Delete plugin from disk.
- -- - Update only this plugin.
- -- - Exclude this plugin from update.
- return callback(_, {})
+methods['textDocument/codeAction'] = function(params, callback)
+ local bufnr = get_confirm_bufnr(params.textDocument.uri)
+ local empty_kind = vim.lsp.protocol.CodeActionKind.Empty
+ local only = params.context.only or { empty_kind }
+ if not (bufnr and vim.tbl_contains(only, empty_kind)) then
+ return callback(nil, {})
+ end
+ local plug_data = get_plug_data_at_lnum(bufnr, params.range.start.line + 1)
+ if not plug_data.name then
+ return callback(nil, {})
+ end
+
+ local function new_action(title, command)
+ return {
+ title = ('%s `%s`'):format(title, plug_data.name),
+ command = { title = title, command = command, arguments = { bufnr, plug_data } },
+ }
+ end
+
+ local res = {}
+ if plug_data.group == 'Update' then
+ vim.list_extend(res, {
+ new_action('Update', 'update_plugin'),
+ new_action('Skip updating', 'skip_update_plugin'),
+ }, 0)
+ end
+ vim.list_extend(res, { new_action('Delete', 'delete_plugin') })
+ callback(nil, res)
end
---- @param params { textDocument: { uri: string }, position: { line: integer, character: integer } }
+local commands = {
+ update_plugin = function(plug_data)
+ vim.pack.update({ plug_data.name }, { force = true, _offline = true })
+ end,
+ skip_update_plugin = function(_) end,
+ delete_plugin = function(plug_data)
+ vim.pack.del({ plug_data.name })
+ end,
+}
+
+-- NOTE: Use `vim.schedule_wrap` to avoid press-enter after choosing code
+-- action via built-in `vim.fn.inputlist()`
+--- @param params { command: string, arguments: table }
+--- @param callback function
+methods['workspace/executeCommand'] = vim.schedule_wrap(function(params, callback)
+ --- @type integer, table
+ local bufnr, plug_data = unpack(params.arguments)
+ local ok, err = pcall(commands[params.command], plug_data)
+ if not ok then
+ return callback({ code = 1, message = err }, {})
+ end
+
+ -- Remove plugin lines (including blank line) to not later act on plugin
+ vim.bo[bufnr].modifiable = true
+ vim.api.nvim_buf_set_lines(bufnr, plug_data.from - 2, plug_data.to, false, {})
+ vim.bo[bufnr].modifiable, vim.bo[bufnr].modified = false, false
+ callback(nil, {})
+end)
+
+--- @param params { textDocument: { uri: string }, position: vim.pack.lsp.Position }
--- @param callback function
methods['textDocument/hover'] = function(params, callback)
local bufnr = get_confirm_bufnr(params.textDocument.uri)
diff --git a/test/functional/plugin/pack_spec.lua b/test/functional/plugin/pack_spec.lua
@@ -1136,6 +1136,7 @@ describe('vim.pack', function()
it('has in-process LSP features', function()
t.skip(not is_jit(), "Non LuaJIT reports errors differently due to 'coxpcall'")
+ track_nvim_echo()
exec_lua(function()
vim.pack.add({
repos_src.fetch,
@@ -1203,6 +1204,116 @@ describe('vim.pack', function()
validate_hover({ 30, 0 }, 'Add version v1.0.0')
validate_hover({ 31, 0 }, 'Add version v0.4')
validate_hover({ 32, 0 }, 'Add version 0.3.1')
+
+ -- textDocument/codeAction
+ n.exec_lua(function()
+ -- Mock `vim.ui.select()` which is a default code action selection
+ _G.select_idx = 0
+
+ ---@diagnostic disable-next-line: duplicate-set-field
+ vim.ui.select = function(items, _, on_choice)
+ _G.select_items = items
+ local idx = _G.select_idx
+ if idx > 0 then
+ on_choice(items[idx], idx)
+ -- Minor delay before continue because LSP cmd execution is async
+ vim.wait(10)
+ end
+ end
+ end)
+
+ local ref_lockfile = get_lock_tbl() --- @type vim.pack.Lock
+
+ local function validate_action(pos, action_titles, select_idx)
+ api.nvim_win_set_cursor(0, pos)
+
+ local lines = api.nvim_buf_get_lines(0, 0, -1, false)
+ n.exec_lua(function()
+ _G.select_items = nil
+ _G.select_idx = select_idx
+ vim.lsp.buf.code_action()
+ end)
+ local titles = vim.tbl_map(function(x) --- @param x table
+ return x.action.title
+ end, n.exec_lua('return _G.select_items or {}'))
+ eq(titles, action_titles)
+
+ -- If no action is asked (like via cancel), should not delete lines
+ if select_idx <= 0 then
+ eq(lines, api.nvim_buf_get_lines(0, 0, -1, false))
+ end
+ end
+
+ -- - Should not include "namespace" header as "plugin at cursor"
+ validate_action({ 1, 1 }, {}, 0)
+ validate_action({ 2, 0 }, {}, 0)
+ -- - Only deletion should be available on errored plugin
+ validate_action({ 3, 1 }, { 'Delete `defbranch`' }, 0)
+ validate_action({ 7, 0 }, { 'Delete `defbranch`' }, 0)
+ -- - Should not include separator blank line as "plugin at cursor"
+ validate_action({ 8, 0 }, {}, 0)
+ validate_action({ 9, 0 }, {}, 0)
+ validate_action({ 10, 0 }, {}, 0)
+ -- - Should also suggest updating related actions if updates available
+ local fetch_actions = { 'Update `fetch`', 'Skip updating `fetch`', 'Delete `fetch`' }
+ validate_action({ 11, 0 }, fetch_actions, 0)
+ validate_action({ 14, 0 }, fetch_actions, 0)
+ validate_action({ 20, 0 }, fetch_actions, 0)
+ validate_action({ 21, 0 }, {}, 0)
+ validate_action({ 22, 0 }, {}, 0)
+ validate_action({ 23, 0 }, {}, 0)
+ -- - Only deletion should be available on plugins without update
+ validate_action({ 24, 0 }, { 'Delete `semver`' }, 0)
+ validate_action({ 28, 0 }, { 'Delete `semver`' }, 0)
+ validate_action({ 32, 0 }, { 'Delete `semver`' }, 0)
+
+ -- - Should correctly perform action and remove plugin's lines
+ local function line_match(lnum, pattern)
+ matches(pattern, api.nvim_buf_get_lines(0, lnum - 1, lnum, false)[1])
+ end
+
+ -- - Delete. Should remove from disk and update lockfile.
+ validate_action({ 3, 0 }, { 'Delete `defbranch`' }, 1)
+ eq(false, pack_exists('defbranch'))
+ line_match(1, '^# Error')
+ line_match(2, '^$')
+ line_match(3, '^# Update')
+
+ ref_lockfile.plugins.defbranch = nil
+ eq(ref_lockfile, get_lock_tbl())
+
+ -- - Skip udating
+ validate_action({ 5, 0 }, fetch_actions, 2)
+ eq({ 'return "fetch main"' }, fn.readfile(fetch_lua_file))
+ line_match(3, '^# Update')
+ line_match(4, '^$')
+ line_match(5, '^# Same')
+
+ -- - Update plugin. Should not re-fetch new data and update lockfile.
+ n.exec('quit')
+ n.exec_lua(function()
+ vim.pack.update({ 'fetch', 'semver' })
+ end)
+ exec_lua('_G.echo_log = {}')
+
+ ref_lockfile.plugins.fetch.rev = git_get_hash('main', 'fetch')
+ repo_write_file('fetch', 'lua/fetch.lua', 'return "fetch new 3"')
+ git_add_commit('Commit to be added 3', 'fetch')
+
+ validate_action({ 3, 0 }, fetch_actions, 1)
+
+ eq({ 'return "fetch new 2"' }, fn.readfile(fetch_lua_file))
+ validate_progress_report('Applying updates', { 'fetch' })
+ line_match(1, '^# Update')
+ line_match(2, '^$')
+ line_match(3, '^# Same')
+
+ eq(ref_lockfile, get_lock_tbl())
+
+ -- - Can still respect `:write` after action
+ n.exec('write')
+ eq('vim.pack: Nothing to update', n.exec_capture('1messages'))
+ eq(api.nvim_get_option_value('filetype', {}), '')
end)
it('has buffer-local mappings', function()