diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index af47bbe908..47b6fa6d55 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -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 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) diff --git a/src/nvim/eval/funcs.c b/src/nvim/eval/funcs.c index 20147a48cb..4c8e26dfa8 100644 --- a/src/nvim/eval/funcs.c +++ b/src/nvim/eval/funcs.c @@ -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 diff --git a/src/nvim/ex_cmds.c b/src/nvim/ex_cmds.c index d0d45d399a..1d9c6aac02 100644 --- a/src/nvim/ex_cmds.c +++ b/src/nvim/ex_cmds.c @@ -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); } diff --git a/src/nvim/lua/executor.c b/src/nvim/lua/executor.c index 4a747848f3..468c706014 100644 --- a/src/nvim/lua/executor.c +++ b/src/nvim/lua/executor.c @@ -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. diff --git a/src/nvim/main.c b/src/nvim/main.c index 4cfec6bd41..55cbdd89b0 100644 --- a/src/nvim/main.c +++ b/src/nvim/main.c @@ -511,10 +511,14 @@ int main(int argc, char **argv) // Decide about window layout for diff mode after reading vimrc. set_window_layout(¶ms); - // 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); } diff --git a/src/nvim/memline.c b/src/nvim/memline.c index 4f929977d3..3d80184a1e 100644 --- a/src/nvim/memline.c +++ b/src/nvim/memline.c @@ -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 diff --git a/src/nvim/spellsuggest.c b/src/nvim/spellsuggest.c index 4f1bae9f9b..5bcb6a03b7 100644 --- a/src/nvim/spellsuggest.c +++ b/src/nvim/spellsuggest.c @@ -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) { diff --git a/src/nvim/tag.c b/src/nvim/tag.c index 8d7caac416..26162d534e 100644 --- a/src/nvim/tag.c +++ b/src/nvim/tag.c @@ -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) { diff --git a/test/functional/core/main_spec.lua b/test/functional/core/main_spec.lua index ee301f0675..283bf4c2b5 100644 --- a/test/functional/core/main_spec.lua +++ b/test/functional/core/main_spec.lua @@ -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', diff --git a/test/functional/ex_cmds/oldfiles_spec.lua b/test/functional/ex_cmds/oldfiles_spec.lua index e9c5cc180b..63ed19fc77 100644 --- a/test/functional/ex_cmds/oldfiles_spec.lua +++ b/test/functional/ex_cmds/oldfiles_spec.lua @@ -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) diff --git a/test/functional/ex_cmds/swapfile_preserve_recover_spec.lua b/test/functional/ex_cmds/swapfile_preserve_recover_spec.lua index c3dc8fff7c..0e827be33f 100644 --- a/test/functional/ex_cmds/swapfile_preserve_recover_spec.lua +++ b/test/functional/ex_cmds/swapfile_preserve_recover_spec.lua @@ -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 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 or click with the mouse (q or empty cancels): ^'), }, none = vim.pesc('{18:^@}'), }) diff --git a/test/functional/lua/ui_select_spec.lua b/test/functional/lua/ui_select_spec.lua index e073bf95c4..0404ebd163 100644 --- a/test/functional/lua/ui_select_spec.lua +++ b/test/functional/lua/ui_select_spec.lua @@ -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)