feat(ui): use vim.ui.select for :oldfiles, :recover

Problem:
followup to 55ceb314ca #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 `<dir>//<dir>/<file>` paths (also
  fixes `swapfilelist()`).
This commit is contained in:
Justin M. Keyes
2026-04-29 01:18:58 +02:00
parent 6a87ef75b3
commit 18d7dd485b
16 changed files with 417 additions and 166 deletions

View File

@@ -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

View File

@@ -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',

View File

@@ -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! <swapfile>" 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(('<Cmd>recover! %s<CR>'):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

View File

@@ -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)