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

@@ -6,10 +6,48 @@ local clear = n.clear
local exec_lua = n.exec_lua
local api = n.api
local eq = t.eq
local neq = t.neq
local write_file = t.write_file
before_each(clear)
--- Mock async vim.ui.select impl. Imitates fzf-lua/telescope/snacks: opens
--- a transient floating window, then schedules on_choice to fire on the next
--- event-loop tick (rather than synchronously).
---
--- Sets `_G._captured` so tests can inspect what was passed to vim.ui.select.
--- @param pick integer|nil 1-based index to "pick" (nil cancels).
local function setup_async_picker(pick)
exec_lua(function()
_G._captured = nil
--- @diagnostic disable-next-line: duplicate-set-field
vim.ui.select = function(items, opts, on_choice)
_G._captured = { items = items, opts = opts }
-- Open a floating window like a real picker would.
local buf = vim.api.nvim_create_buf(false, true)
local win = vim.api.nvim_open_win(buf, false, {
relative = 'editor',
row = 1,
col = 1,
width = 30,
height = math.min(#items, 5),
})
_G._captured.win = win
-- Defer the choice so the wait actually has to pump events.
vim.defer_fn(function()
if vim.api.nvim_win_is_valid(win) then
vim.api.nvim_win_close(win, true)
end
if pick then
on_choice(items[pick], pick)
else
on_choice(nil, nil)
end
end, 30)
end
end, pick)
end
describe('vim.ui.select()', function()
it('can select an item', function()
local result = exec_lua [[
@@ -165,4 +203,151 @@ describe('vim.ui.select()', function()
eq('helo', api.nvim_buf_get_lines(0, 0, -1, false)[1])
end)
end)
-- The selection step blocks the C caller via vim.wait(). Async pickers
-- (fzf-lua, telescope, snacks, …) open a transient window and call on_choice
-- on a later event-loop tick. These tests exercise the wait+resume path for
-- each integration. If the C caller doesn't allow the picker to repaint or
-- pump events, these will hang or fail.
describe('async picker', function()
it('z= dispatches selection from a deferred callback', function()
api.nvim_set_option_value('spell', true, {})
api.nvim_set_option_value('spelllang', 'en_us', {})
api.nvim_buf_set_lines(0, 0, -1, false, { 'helo' })
setup_async_picker(1)
exec_lua(function()
vim.cmd('normal! gg0z=')
end)
neq('helo', api.nvim_buf_get_lines(0, 0, -1, false)[1])
local kind = exec_lua([[return _G._captured and _G._captured.opts.kind]])
eq('spell', kind)
end)
it(':tselect dispatches selection from a deferred callback', function()
write_file('XselTagA.c', 'int foo;\n')
write_file('XselTagB.c', 'int foo = 1;\n')
finally(function()
os.remove('XselTagA.c')
os.remove('XselTagB.c')
os.remove('XselTags')
end)
write_file(
'XselTags',
'!_TAG_FILE_FORMAT\t2\t/extended format/\n'
.. 'foo\tXselTagA.c\t/^int foo;$/;"\tv\n'
.. 'foo\tXselTagB.c\t/^int foo = 1;$/;"\tv\n'
)
api.nvim_set_option_value('tags', 'XselTags', {})
setup_async_picker(2)
exec_lua(function()
vim.cmd('tselect foo')
end)
eq('XselTagB.c', api.nvim_eval('expand("%:t")'))
local kind = exec_lua([[return _G._captured and _G._captured.opts.kind]])
eq('tag', kind)
end)
--- Mock fzf-lua-style picker: opens a floating window with a *terminal*
--- buffer running a small shell command. When the command exits we treat
--- the user as having "picked" `pick`. This more closely exercises the
--- code paths that block real terminal-based pickers in ex-command
--- contexts (RedrawingDisabled, mode dispatch, terminal_loop, …).
local function setup_term_picker(pick)
exec_lua(function()
_G._captured = nil
--- @diagnostic disable-next-line: duplicate-set-field
vim.ui.select = function(items, opts, on_choice)
_G._captured = { items = items, opts = opts }
local buf = vim.api.nvim_create_buf(false, true)
local win = vim.api.nvim_open_win(buf, true, {
relative = 'editor',
row = 1,
col = 1,
width = 30,
height = math.min(#items, 5),
})
-- Sleep briefly to mimic an interactive terminal session, then exit.
vim.fn.jobstart({ 'sh', '-c', 'sleep 0.05' }, {
term = true,
on_exit = function()
if vim.api.nvim_win_is_valid(win) then
vim.api.nvim_win_close(win, true)
end
if pick then
on_choice(items[pick], pick)
else
on_choice(nil, nil)
end
end,
})
end
end, pick)
end
it('z= dispatches selection from a terminal-based picker', function()
api.nvim_set_option_value('spell', true, {})
api.nvim_set_option_value('spelllang', 'en_us', {})
api.nvim_buf_set_lines(0, 0, -1, false, { 'helo' })
setup_term_picker(1)
exec_lua(function()
vim.cmd('normal! gg0z=')
end)
neq('helo', api.nvim_buf_get_lines(0, 0, -1, false)[1])
end)
it(':tselect dispatches selection from a terminal-based picker', function()
write_file('XselTagA.c', 'int foo;\n')
write_file('XselTagB.c', 'int foo = 1;\n')
finally(function()
os.remove('XselTagA.c')
os.remove('XselTagB.c')
os.remove('XselTags')
end)
write_file(
'XselTags',
'!_TAG_FILE_FORMAT\t2\t/extended format/\n'
.. 'foo\tXselTagA.c\t/^int foo;$/;"\tv\n'
.. 'foo\tXselTagB.c\t/^int foo = 1;$/;"\tv\n'
)
api.nvim_set_option_value('tags', 'XselTags', {})
setup_term_picker(2)
exec_lua(function()
vim.cmd('tselect foo')
end)
eq('XselTagB.c', api.nvim_eval('expand("%:t")'))
end)
it(':browse oldfiles dispatches selection from a deferred callback', function()
finally(function()
os.remove('XselOldA')
os.remove('XselOldB')
end)
write_file('XselOldA', 'a\n')
write_file('XselOldB', 'b\n')
local cwd = exec_lua([[return vim.uv.cwd()]])
setup_async_picker(2)
exec_lua(function(cwd_)
-- v:oldfiles is normally populated via shada; inject directly for the test.
vim.v.oldfiles = { cwd_ .. '/XselOldA', cwd_ .. '/XselOldB' }
vim.cmd('browse oldfiles')
-- :browse oldfiles is async — wait for on_choice to fire and edit the file.
vim.wait(1000, function()
return vim.fn.expand('%:t') == 'XselOldB'
end)
end, cwd)
eq('XselOldB', api.nvim_eval('expand("%:t")'))
local kind = exec_lua([[return _G._captured and _G._captured.opts.kind]])
eq('oldfiles', kind)
end)
end)
end)