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

@@ -216,10 +216,11 @@ TUI
UI
|:tselect| delegates to |vim.ui.select()| instead of a bespoke internal
selection routine.
• |z=| (spell suggest) delegates to |vim.ui.select()| instead of a bespoke
internal selection routine.
These builtin "picker" menus delegate to |vim.ui.select()|:
• :browse oldfiles
• |:recover|
• |:tselect|
• |z=| (spell suggest)
VIMSCRIPT

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)

View File

@@ -7352,7 +7352,7 @@ static void f_substitute(typval_T *argvars, typval_T *rettv, EvalFuncData fptr)
static void f_swapfilelist(typval_T *argvars, typval_T *rettv, EvalFuncData fptr)
{
tv_list_alloc_ret(rettv, kListLenUnknown);
recover_names(NULL, false, rettv->vval.v_list, 0, NULL);
recover_names(NULL, false, rettv->vval.v_list);
}
/// "swapinfo(swap_filename)" function

View File

@@ -57,6 +57,7 @@
#include "nvim/highlight_group.h"
#include "nvim/indent.h"
#include "nvim/input.h"
#include "nvim/lua/executor.h"
#include "nvim/macros_defs.h"
#include "nvim/main.h"
#include "nvim/mark.h"
@@ -5033,54 +5034,8 @@ char *skip_vimgrep_pat(char *p, char **s, int *flags)
return p;
}
/// List v:oldfiles in a nice way.
/// `:oldfiles` (sync) and `:browse oldfiles` (async).
void ex_oldfiles(exarg_T *eap)
{
list_T *l = get_vim_var_list(VV_OLDFILES);
int nr = 0;
if (l == NULL) {
msg(_("No old files"), 0);
return;
}
msg_start();
msg_scroll = true;
TV_LIST_ITER(l, li, {
if (got_int) {
break;
}
nr++;
const char *fname = tv_get_string(TV_LIST_ITEM_TV(li));
if (!message_filtered(fname)) {
msg_outnum(nr);
msg_puts(": ");
msg_outtrans(tv_get_string(TV_LIST_ITEM_TV(li)), 0, false);
msg_clr_eos();
msg_putchar('\n');
os_breakcheck();
}
});
// Assume "got_int" was set to truncate the listing.
got_int = false;
// File selection prompt on ":browse oldfiles"
if (cmdmod.cmod_flags & CMOD_BROWSE) {
quit_more = false;
nr = prompt_for_input(NULL, 0, false, NULL);
msg_starthere();
if (nr > 0 && nr <= tv_list_len(l)) {
const char *const p = tv_list_find_str(l, nr - 1);
if (p == NULL) {
return;
}
char *const s = expand_env_save((char *)p);
eap->arg = s;
eap->cmdidx = CMD_edit;
cmdmod.cmod_flags &= ~CMOD_BROWSE;
do_exedit(eap, NULL);
xfree(s);
}
}
nlua_call_excmd("vim._core.ex_cmd", "ex_oldfiles", eap, &cmdmod, NULL);
}

View File

@@ -167,6 +167,14 @@ static void nlua_push_cmdmod(lua_State *lstate, const cmdmod_T *cmod)
lua_setfield(lstate, -2, "lockmarks");
lua_pushboolean(lstate, cmod->cmod_flags & CMOD_NOSWAPFILE);
lua_setfield(lstate, -2, "noswapfile");
// ":filter[!] /pattern/" modifier (same shape as `nvim_parse_cmd().mods.filter`).
lua_newtable(lstate);
lua_pushstring(lstate, cmod->cmod_filter_pat ? cmod->cmod_filter_pat : "");
lua_setfield(lstate, -2, "pattern");
lua_pushboolean(lstate, cmod->cmod_filter_force);
lua_setfield(lstate, -2, "force");
lua_setfield(lstate, -2, "filter");
}
/// Pushes common exarg_T fields (bang, line1, line2, …) onto a table at the top of the stack.

View File

@@ -511,10 +511,14 @@ int main(int argc, char **argv)
// Decide about window layout for diff mode after reading vimrc.
set_window_layout(&params);
// Recovery mode without a file name: List swap files.
// Uses the 'dir' option, therefore it must be after the initializations.
// "nvim -r" (recovery mode) without a file name: List swap files.
if (recoverymode && fname == NULL) {
recover_names(NULL, true, NULL, 0, NULL);
typval_T items_tv;
tv_list_alloc_ret(&items_tv, 0);
recover_names(NULL, false, items_tv.vval.v_list);
typval_T lua_args[] = { items_tv, { .v_type = VAR_UNKNOWN } };
nlua_call_vimfn("vim._core.swapfile", "list_swaps", lua_args, NULL);
tv_clear(&items_tv);
os_exit(0);
}

View File

@@ -65,6 +65,7 @@
#include "nvim/globals.h"
#include "nvim/highlight_defs.h"
#include "nvim/input.h"
#include "nvim/lua/executor.h"
#include "nvim/macros_defs.h"
#include "nvim/main.h"
#include "nvim/map_defs.h"
@@ -789,28 +790,27 @@ void ml_recover(bool checkext)
} else {
directly = false;
// count the number of matching swapfiles
len = recover_names(fname, false, NULL, 0, NULL);
if (len == 0) { // no swapfiles found
// Enumerate matching swapfiles into items_tv.
typval_T items_tv;
tv_list_alloc_ret(&items_tv, 0);
recover_names(fname, true, items_tv.vval.v_list);
int n_swaps = tv_list_len(items_tv.vval.v_list);
if (n_swaps == 0) {
tv_clear(&items_tv);
semsg(_("E305: No swap file found for %s"), fname);
goto theend;
}
int i;
if (len == 1) { // one swapfile found, use it
i = 1;
} else { // several swapfiles found, choose
// list the names of the swapfiles
recover_names(fname, true, NULL, 0, NULL);
if (!ui_has(kUIMessages)) {
msg_putchar('\n');
}
i = prompt_for_input(_("Enter number of swap file to use (0 to quit): "), 0, false, NULL);
if (i < 1 || i > len) {
goto theend;
}
if (n_swaps > 1) {
// Several swapfiles found: prompt (async) via vim.ui.select().
typval_T lua_args[] = { items_tv, { .v_type = VAR_UNKNOWN } };
nlua_call_vimfn("vim._core.swapfile", "select_swap", lua_args, NULL);
tv_clear(&items_tv);
goto theend;
}
// get the swapfile name that will be used
recover_names(fname, false, NULL, i, &fname_used);
// One swapfile: use it directly.
fname_used = xstrdup(tv_list_first(items_tv.vval.v_list)->li_tv.vval.v_string);
tv_clear(&items_tv);
}
if (fname_used == NULL) {
goto theend; // user chose invalid number.
@@ -1274,28 +1274,22 @@ theend:
}
}
/// Find the names of swapfiles in current directory and the directory given
/// with the 'directory' option.
/// Enumerate swapfiles for `fname` (or for the global swap dir if `fname` is NULL),
/// appending each found path to `ret_list`.
///
/// Used to:
/// - list the swapfiles for "nvim -r"
/// - count the number of swapfiles when recovering
/// - list the swapfiles when recovering
/// - list the swapfiles for swapfilelist()
/// - find the name of the n'th swapfile when recovering
/// Used by `:recover` (`ml_recover()`), `nvim -r`, and `swapfilelist()`.
///
/// @param fname base for swapfile name
/// @param do_list when true, list the swapfile names
/// @param ret_list when not NULL add file names to it
/// @param nr when non-zero, return nr'th swapfile name
/// @param fname_out result when "nr" > 0
int recover_names(char *fname, bool do_list, list_T *ret_list, int nr, char **fname_out)
/// @param fname base for swapfile name, or NULL to list every swapfile in 'directory'.
/// @param skip_curbuf exclude the current buffer's own active swapfile (used by `:recover`,
/// not by `swapfilelist()`).
/// @param ret_list receives the paths.
void recover_names(char *fname, bool skip_curbuf, list_T *ret_list)
FUNC_ATTR_NONNULL_ARG(3)
{
int num_names;
char *(names[6]);
char *tail;
char *p;
int file_count = 0;
char **files;
char *fname_res = NULL;
#ifdef HAVE_READLINK
@@ -1312,14 +1306,6 @@ int recover_names(char *fname, bool do_list, list_T *ret_list, int nr, char **fn
#endif
}
msg_ext_skip_flush = true;
if (do_list) {
// use msg() to start the scrolling properly
msg_ext_set_kind("list_cmd");
msg(_("Swap files found:"), 0);
msg_putchar('\n');
}
// Do the loop for every directory in 'directory'.
// First allocate some memory to put the directory name in.
String dir_name;
@@ -1375,7 +1361,7 @@ int recover_names(char *fname, bool do_list, list_T *ret_list, int nr, char **fn
// When no swapfile found, wildcard expansion might have failed (e.g.
// not able to execute the shell).
// Try finding a swapfile by simply adding ".swp" to the file name.
if (*dirp == NUL && file_count + num_files == 0 && fname != NULL) {
if (*dirp == NUL && tv_list_len(ret_list) + num_files == 0 && fname != NULL) {
char *swapname = modname(fname_res, ".swp", true);
if (swapname != NULL) {
if (os_path_exists(swapname)) {
@@ -1389,10 +1375,9 @@ int recover_names(char *fname, bool do_list, list_T *ret_list, int nr, char **fn
}
// Remove swapfile name of the current buffer, it must be ignored.
// But keep it for swapfilelist().
if (curbuf->b_ml.ml_mfp != NULL
&& (p = curbuf->b_ml.ml_mfp->mf_fname) != NULL
&& ret_list == NULL) {
if (skip_curbuf
&& curbuf->b_ml.ml_mfp != NULL
&& (p = curbuf->b_ml.ml_mfp->mf_fname) != NULL) {
for (int i = 0; i < num_files; i++) {
// Do not expand wildcards, on Windows would try to expand
// "%tmp%" in "%tmp%file"
@@ -1411,50 +1396,9 @@ int recover_names(char *fname, bool do_list, list_T *ret_list, int nr, char **fn
}
}
}
if (nr > 0) {
file_count += num_files;
if (nr <= file_count) {
*fname_out = xstrdup(files[nr - 1 + num_files - file_count]);
dirp = ""; // stop searching
}
} else if (do_list) {
if (dir_name.data[0] == '.' && dir_name.data[1] == NUL) {
if (fname == NULL) {
msg_puts(_(" In current directory:\n"));
} else {
msg_puts(_(" Using specified name:\n"));
}
} else {
msg_puts(_(" In directory "));
msg_home_replace(dir_name.data);
msg_puts(":\n");
}
if (num_files) {
for (int i = 0; i < num_files; i++) {
// print the swapfile name
msg_outnum(++file_count);
msg_puts(". ");
msg_puts(path_tail(files[i]));
msg_putchar('\n');
StringBuilder msg = KV_INITIAL_VALUE;
kv_resize(msg, IOSIZE);
swapfile_info(files[i], &msg);
bool need_clear = false;
msg_multiline(cbuf_as_string(msg.items, msg.size), 0, false, false, &need_clear);
kv_destroy(msg);
}
} else {
msg_puts(_(" -- none --\n"));
}
ui_flush();
} else if (ret_list != NULL) {
for (int i = 0; i < num_files; i++) {
String name = concat_fnames(dir_name, cstr_as_string(files[i]), true);
tv_list_append_allocated_string(ret_list, name.data);
}
} else {
file_count += num_files;
for (int i = 0; i < num_files; i++) {
// `files[i]` is already a full path (from `expand_wildcards`).
tv_list_append_allocated_string(ret_list, xstrdup(files[i]));
}
for (int i = 0; i < num_names; i++) {
@@ -1464,9 +1408,7 @@ int recover_names(char *fname, bool do_list, list_T *ret_list, int nr, char **fn
FreeWild(num_files, files);
}
}
msg_ext_skip_flush = false;
xfree(dir_name.data);
return file_count;
}
/// Append the full path to name with path separators made into percent

View File

@@ -482,7 +482,7 @@ static int select_spell_suggestion(suginfo_T *sug)
.vval.v_string = xstrnsave(sug->su_badptr, (size_t)sug->su_badlen) };
typval_T lua_args[] = { items_tv, bad_tv, { .v_type = VAR_UNKNOWN } };
typval_T rettv = TV_INITIAL_VALUE;
nlua_call_vimfn("vim._core.spell", "suggest_select", lua_args, &rettv);
nlua_call_vimfn("vim._core.spell", "select_suggest", lua_args, &rettv);
int idx = 0;
if (rettv.v_type == VAR_NUMBER) {

View File

@@ -832,7 +832,7 @@ static int select_tag_match(bool new_tag, bool use_tagstack, int num_matches, ch
typval_T lua_args[] = { items_tv, { .v_type = VAR_UNKNOWN } };
typval_T rettv = TV_INITIAL_VALUE;
nlua_call_vimfn("vim._core.tag", "select", lua_args, &rettv);
nlua_call_vimfn("vim._core.tag", "select_tag", lua_args, &rettv);
int idx = 0;
if (rettv.v_type == VAR_NUMBER) {

View File

@@ -232,6 +232,7 @@ describe('vim._core', function()
'vim._core.shared',
'vim._core.spell',
'vim._core.stringbuffer',
'vim._core.swapfile',
'vim._core.system',
'vim._core.table',
'vim._core.tag',

View File

@@ -85,6 +85,15 @@ describe(':oldfiles', function()
oldfiles = get_oldfiles('filter! file_ oldfiles')
eq({ another }, oldfiles)
-- The original v:oldfiles index is preserved in the output (matches `message_filtered()` behavior).
local v_oldfiles = api.nvim_get_vvar('oldfiles')
local raw = eval([[split(execute('filter file_ oldfiles'), "\n")]])
for _, line in ipairs(raw) do
local idx, path = line:match('^(%d+):%s+(.+)$')
ok(idx ~= nil, 'numbered', line)
eq(path, v_oldfiles[tonumber(idx)])
end
end)
end)

View File

@@ -160,24 +160,34 @@ describe("preserve and (R)ecover with custom 'directory'", function()
{
content = { { '' } },
pos = 0,
prompt = 'Enter number of swap file to use (0 to quit): ',
-- Default vim.ui.select prompt.
prompt = 'Type number and <Enter> or click with the mouse (q or empty cancels): ',
},
},
condition = function()
msg = msg or screen.messages[1]
eq(true, msg.content[1][2]:match('Swap.*none --') ~= nil)
eq('list_cmd', msg.kind)
-- Concatenate all chunks (each chunk is { 'text' } or { hl_id, 'text', 'group' }).
local text = ''
for _, chunk in ipairs(msg.content) do
text = text .. (#chunk >= 2 and chunk[2] or chunk[1])
end
-- New ui.select-driven prompt; rich info from format_item.
eq(true, text:match('Enter number of swap file to use') ~= nil)
eq(true, text:match('%.swo') ~= nil)
eq(true, text:match('%.swp') ~= nil)
eq(true, text:match('host name:') ~= nil)
eq('confirm', msg.kind)
screen.messages = {}
end,
})
else
screen:expect({
any = {
'\nSwap files found:',
'\n In directory ',
vim.pesc('\n1. '),
vim.pesc('\n2. '),
vim.pesc('\nEnter number of swap file to use (0 to quit): ^'),
vim.pesc('Enter number of swap file to use (q or empty cancels):'),
'\n1:.*%.swo',
'\n2:.*%.swp',
'host name:',
vim.pesc('Type number and <Enter> or click with the mouse (q or empty cancels): ^'),
},
none = vim.pesc('{18:^@}'),
})

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)