From 18d7dd485be57b85308d1e47ab44ce21e7a80be3 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Wed, 29 Apr 2026 01:18:58 +0200 Subject: feat(ui): use vim.ui.select for :oldfiles, :recover Problem: followup to 55ceb314ca96 #39478 `:oldfiles` and swapfile `:recover` do not delegate to `vim.ui.select`. Solution: - Delegate to `vim.ui.select`. - Fix a long-standing `recover_names` bug where `concat_fnames(dir_name, files[i], true)` produced malformed `///` paths (also fixes `swapfilelist()`). --- runtime/lua/vim/_core/ex_cmd.lua | 52 +++++++++++++++++++++++ runtime/lua/vim/_core/spell.lua | 7 ++-- runtime/lua/vim/_core/swapfile.lua | 86 ++++++++++++++++++++++++++++++++++++++ runtime/lua/vim/_core/tag.lua | 7 ++-- 4 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 runtime/lua/vim/_core/swapfile.lua (limited to 'runtime/lua/vim') diff --git a/runtime/lua/vim/_core/ex_cmd.lua b/runtime/lua/vim/_core/ex_cmd.lua index 81232abf74..c877231ea5 100644 --- a/runtime/lua/vim/_core/ex_cmd.lua +++ b/runtime/lua/vim/_core/ex_cmd.lua @@ -11,6 +11,19 @@ local N_ = vim.fn.gettext local M = {} +--- Apply the `:filter[!] /pattern/` modifier to a single message. See also `message_filtered()`. +--- +--- @param filter vim.api.keyset.cmd_mods_filter ":filter" mod. +--- @param msg string Message to test. +--- @return boolean # True if `msg` should be skipped (not displayed). +function M.filter(filter, msg) + if not filter or filter.pattern == '' then + return false + end + local match = vim.regex(filter.pattern):match_str(msg) ~= nil + return match == filter.force +end + --- @param msg string local function echo_err(msg) api.nvim_echo({ { msg } }, true, { err = true }) @@ -266,4 +279,43 @@ function M.ex_uptime() api.nvim_echo({ { N_('Up %s'):format(uptime_display) } }, true, {}) end +--- `:oldfiles` and `:browse oldfiles`. Lists v:oldfiles (plain `:oldfiles`) or shows (async) +--- vim.ui.select() picker (`:browse oldfiles`) and edits the chosen file. +--- @param eap vim._core.ExCmdArgs +function M.ex_oldfiles(eap) + local files = vim.v.oldfiles + if not files or #files == 0 then + api.nvim_echo({ { N_('No old files') } }, false, {}) + return + end + + if eap.smods.browse then + vim.ui.select(files, { + prompt = N_('Select an oldfile:'), + kind = 'oldfiles', + }, function(_, idx) + if idx then + api.nvim_cmd({ + cmd = 'edit', + args = { vim.fn.expand(files[idx]) }, + magic = { file = false, bar = true }, -- May contain '%' (e.g. swapfiles), don't expand. + }, {}) + end + end) + return + end + + -- `:oldfiles`: list the entries. Honor `:filter /pat/[!]` per entry. + local lines = {} ---@type [string][] + for i, f in ipairs(files) do + if not M.filter(eap.smods.filter, f) then + lines[#lines + 1] = { ('%d: %s\n'):format(i, f) } + end + end + if #lines == 0 then + return + end + api.nvim_echo(lines, false, {}) +end + return M diff --git a/runtime/lua/vim/_core/spell.lua b/runtime/lua/vim/_core/spell.lua index d6bdc010a3..11ad39d064 100644 --- a/runtime/lua/vim/_core/spell.lua +++ b/runtime/lua/vim/_core/spell.lua @@ -10,13 +10,12 @@ local M = {} --- @field altscore? integer Secondary score (only set when 'spellsuggest' contains "double" or "best"). --- @field salscore? boolean True if the score came from sound-alike comparison (only set alongside `altscore`). ---- Called from `spell_suggest()` (`z=`) to let the user pick from `items` via ---- |vim.ui.select()|. +--- Implements `spell_suggest()` (`z=`) vim.ui.select(). --- --- @param items vim._core.spell.Suggestion[] --- @param bad string The misspelled word being replaced. ---- @return integer? # 1-based index of the chosen suggestion, or nil if cancelled. -function M.suggest_select(items, bad) +--- @return integer? # Selected item (1-indexed), or nil if cancelled. +function M.select_suggest(items, bad) return select_blocking(items, { prompt = N_('Change "%s" to:'):format(bad), kind = 'spell', diff --git a/runtime/lua/vim/_core/swapfile.lua b/runtime/lua/vim/_core/swapfile.lua new file mode 100644 index 0000000000..7e3bee993f --- /dev/null +++ b/runtime/lua/vim/_core/swapfile.lua @@ -0,0 +1,86 @@ +local api = vim.api +local N_ = vim.fn.gettext + +local M = {} + +--- Renders a swap file as a multi-line block: +--- ``` +--- %home%foo%bar%README.md.swl +--- dated: Thu Apr 23 17:25:52 2026 +--- file name: ~foo/bar/README.md +--- modified: no +--- user name: justin host name: minime +--- process ID: 10521 (STILL RUNNING) +--- ``` +--- @param path string +--- @return string +local function format_swap(path) + local info = vim.fn.swapinfo(path) + local mtime = info.mtime and vim.fn.strftime('%a %b %d %H:%M:%S %Y', info.mtime) or '?' + local lines = { + vim.fs.basename(path), + (' dated: %s'):format(mtime), + } + if info.error then + lines[#lines + 1] = (' [%s]'):format(info.error) + else + lines[#lines + 1] = (' file name: %s'):format( + info.fname == '' and '[No Name]' or info.fname + ) + lines[#lines + 1] = (' modified: %s'):format(info.dirty == 1 and 'YES' or 'no') + if info.user ~= '' or info.host ~= '' then + local parts = {} ---@type string[] + if info.user ~= '' then + parts[#parts + 1] = ('user name: %s'):format(info.user) + end + if info.host ~= '' then + parts[#parts + 1] = ('host name: %s'):format(info.host) + end + lines[#lines + 1] = (' %s'):format(table.concat(parts, ' ')) + end + if info.pid > 0 then + lines[#lines + 1] = (' process ID: %d (STILL RUNNING)'):format(info.pid) + end + end + return table.concat(lines, '\n') +end + +--- Implements `:recover` (when there are multiple swap files): let the user pick via vim.ui.select(). +--- +--- async: returns immediately, then schedules `:recover {path}` on the chosen swapfile. +--- +--- @param items string[] List of swapfile paths. +function M.select_swap(items) + vim.ui.select(items, { + prompt = N_('Enter number of swap file to use (q or empty cancels):'), + kind = 'swap', + format_item = format_swap, + }, function(_, idx) + if not idx then + return + end + -- Queue ":recover! " as user input, so the recursive recovery runs via the normal + -- input-dispatch loop. Using vim.schedule + vim.cmd can hang bc of "Press ENTER". + vim.fn.feedkeys( + vim.keycode(('recover! %s'):format(vim.fn.fnameescape(items[idx]))), + 'in' + ) + end) +end + +--- Implements `nvim -r` (no arg): list every swapfile found in 'directory'. +--- +--- @param items string[] List of swapfile paths. +function M.list_swaps(items) + local lines = { { N_('Swap files found:') .. '\n' } } ---@type [string][] + if #items == 0 then + lines[#lines + 1] = { ' ' .. N_('-- none --') } + else + for i, path in ipairs(items) do + lines[#lines + 1] = { ('%d. %s\n'):format(i, format_swap(path)) } + end + end + api.nvim_echo(lines, false, {}) +end + +return M diff --git a/runtime/lua/vim/_core/tag.lua b/runtime/lua/vim/_core/tag.lua index 9d6c3cffbd..12bf4c5eaa 100644 --- a/runtime/lua/vim/_core/tag.lua +++ b/runtime/lua/vim/_core/tag.lua @@ -11,12 +11,11 @@ local M = {} --- @field extra? string --- @field cur boolean True if this is the currently-active tagstack match. ---- Called from `do_tag()` (`:tselect`, ambiguous `:tag`, etc.) to let the user ---- pick from `matches` via |vim.ui.select()|. +--- Implements `do_tag()` (`:tselect`, ambiguous `:tag`, …) via vim.ui.select(). --- --- @param items vim._core.tag.Match[] One per matching tag. ---- @return integer? # 1-based index of the chosen tag, or nil if cancelled. -function M.select(items) +--- @return integer? # Selected item (1-indexed), or nil if cancelled. +function M.select_tag(items) local taglen = 18 for _, m in ipairs(items) do taglen = math.max(taglen, vim.fn.strdisplaywidth(m.tag) + 2) -- cgit From 7c4845ff463b63324c4d45f03fdd58b7f70e54ba Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Thu, 30 Apr 2026 02:23:28 +0200 Subject: fix(ui): z=, tselect with async vim.ui.select Problem: After 55ceb31, z= and tselect don't work if `vim.ui.select` is an async provider (especially terminal buffers). Solution: Drop the `vim.wait()` approach, use an async approach. fix #39506 --- runtime/lua/vim/_core/ex_cmd.lua | 3 ++- runtime/lua/vim/_core/spell.lua | 18 +++++++++++++----- runtime/lua/vim/_core/tag.lua | 31 +++++++++++++++++++++++-------- 3 files changed, 38 insertions(+), 14 deletions(-) (limited to 'runtime/lua/vim') diff --git a/runtime/lua/vim/_core/ex_cmd.lua b/runtime/lua/vim/_core/ex_cmd.lua index c877231ea5..a1a7ddb07d 100644 --- a/runtime/lua/vim/_core/ex_cmd.lua +++ b/runtime/lua/vim/_core/ex_cmd.lua @@ -6,7 +6,8 @@ local uv = vim.uv local N_ = vim.fn.gettext --- Parsed ex command arguments for builtin commands, passed from C via `nlua_call_excmd`. ---- Inherits fields from user command args: args, bang, line1, line2, range, count, reg, smods. +--- Inherits fields from user command args: name, args, bang, line1, line2, range, count, reg, smods. +--- Note: For builtin commands `name` is the canonical command name. --- @class vim._core.ExCmdArgs : vim.api.keyset.create_user_command.command_args local M = {} diff --git a/runtime/lua/vim/_core/spell.lua b/runtime/lua/vim/_core/spell.lua index 11ad39d064..888c28da32 100644 --- a/runtime/lua/vim/_core/spell.lua +++ b/runtime/lua/vim/_core/spell.lua @@ -1,4 +1,3 @@ -local select_blocking = require('vim._core.ui').select_blocking local N_ = vim.fn.gettext local M = {} @@ -10,13 +9,15 @@ local M = {} --- @field altscore? integer Secondary score (only set when 'spellsuggest' contains "double" or "best"). --- @field salscore? boolean True if the score came from sound-alike comparison (only set alongside `altscore`). ---- Implements `spell_suggest()` (`z=`) vim.ui.select(). +--- Implements `spell_suggest()` (`z=`) via vim.ui.select(). +--- +--- async: returns immediately, the chosen suggestion is applied later +--- by re-running `:normal! [idx]z=` from `on_choice`. --- --- @param items vim._core.spell.Suggestion[] --- @param bad string The misspelled word being replaced. ---- @return integer? # Selected item (1-indexed), or nil if cancelled. function M.select_suggest(items, bad) - return select_blocking(items, { + vim.ui.select(items, { prompt = N_('Change "%s" to:'):format(bad), kind = 'spell', format_item = function(s) @@ -29,7 +30,14 @@ function M.select_suggest(items, bad) end return ('"%s"%s%s'):format(s.word, extra, score) end, - }) + }, function(_, idx) + if not idx then + return + end + -- Queue ":normal! [idx]z=" as user input, so the recursive spell_suggest runs via the normal + -- input-dispatch loop. Using vim.schedule + vim.cmd can hang bc of "Press ENTER". + vim.fn.feedkeys(vim.keycode(('normal! %dz='):format(idx)), 'in') + end) end return M diff --git a/runtime/lua/vim/_core/tag.lua b/runtime/lua/vim/_core/tag.lua index 12bf4c5eaa..3c0c2f544f 100644 --- a/runtime/lua/vim/_core/tag.lua +++ b/runtime/lua/vim/_core/tag.lua @@ -1,4 +1,3 @@ -local select_blocking = require('vim._core.ui').select_blocking local N_ = vim.fn.gettext local M = {} @@ -13,31 +12,47 @@ local M = {} --- Implements `do_tag()` (`:tselect`, ambiguous `:tag`, …) via vim.ui.select(). --- ---- @param items vim._core.tag.Match[] One per matching tag. ---- @return integer? # Selected item (1-indexed), or nil if cancelled. -function M.select_tag(items) +--- async: returns immediately, the chosen tag is applied later by re-running +--- `:[mods] [idx]tag {tagname}` (or `stag`) from `on_choice`. +--- +--- @param eap vim._core.ExCmdArgs Original :tselect/:stselect/… invocation. +--- @param extra { items: vim._core.tag.Match[], tagname: string } +function M.select_tag(eap, extra) + local items, tagname = extra.items, extra.tagname + -- :stag/:stselect/:stjump need a split when re-invoked. + local stag = eap.name:sub(1, 1) == 's' + -- `eap.mods` is the raw modifier string (e.g. ":vert silent"). + local mods_str = eap.mods ~= '' and (eap.mods .. ' ') or '' + local taglen = 18 for _, m in ipairs(items) do taglen = math.max(taglen, vim.fn.strdisplaywidth(m.tag) + 2) end - return select_blocking(items, { + vim.ui.select(items, { prompt = N_('Type number and (q or empty cancels):'), kind = 'tag', format_item = function(m) local marker = m.cur and '>' or ' ' local kind = m.kind or '' - local extra = m.extra and (' ' .. m.extra) or '' return ('%s %s %-4s %-' .. taglen .. 's %s%s'):format( marker, m.pri, kind, m.tag, m.file, - extra + m.extra and (' ' .. m.extra) or '' ) end, - }) + }, function(_, idx) + if not idx then + return + end + -- Queue ":[mods] [idx](s)tag {tagname}" as user input, so the recursive do_tag runs via the + -- normal input-dispatch loop. Using vim.schedule + vim.cmd can hang bc of "Press ENTER". + local cmd = stag and 'stag' or 'tag' + vim.fn.feedkeys(vim.keycode(('%s%d%s %s'):format(mods_str, idx, cmd, tagname)), 'in') + end) end return M -- cgit