From 1359bb0537ba60ddf30b11387802cdb0640cf899 Mon Sep 17 00:00:00 2001 From: Serpent7776 Date: Tue, 29 Sep 2026 18:51:31 +0200 Subject: [PATCH] fix(picker): keep prompt in insert mode when foreign autocmds leave the window Plugins that run `windo`/`wincmd` from BufWinEnter or FileType autocmds (e.g. FastFold) walk through the prompt window whenever the preview buffer is swapped or gets a filetype. Neovim stops insert mode when a prompt buffer window is left, so typed keys ended up in normal mode. - restore insert mode when it was dropped without a key being typed - hold keys typed before insert mode is back and replay them afterwards - stop using `normal!` in preview paths, it realized the pending mode change inside the preview window --- lua/fff/file_picker/preview.lua | 19 ++--- lua/fff/picker_ui/picker_ui.lua | 2 +- lua/fff/picker_ui/ui_creator.lua | 122 ++++++++++++++++++++++++++++++- 3 files changed, 126 insertions(+), 17 deletions(-) diff --git a/lua/fff/file_picker/preview.lua b/lua/fff/file_picker/preview.lua index a938cf3f9..6a80eb9e2 100644 --- a/lua/fff/file_picker/preview.lua +++ b/lua/fff/file_picker/preview.lua @@ -683,7 +683,7 @@ function M.scroll(lines) vim.api.nvim_win_call(M.state.winid, function() vim.api.nvim_win_set_cursor(M.state.winid, { target_line, 0 }) - vim.cmd('normal! zt') + vim.fn.winrestview({ topline = target_line }) end) end end @@ -837,18 +837,9 @@ function M.clear_preview_visual_state(bufnr) -- Clear location highlights if M.state.location_namespace then location_utils.clear_location_highlights(bufnr, M.state.location_namespace) end - local wins = vim.fn.win_findbuf(bufnr) - - for _, win in ipairs(wins) do - if vim.api.nvim_win_is_valid(win) then - -- Reset folds - pcall(vim.api.nvim_win_call, win, function() - if vim.fn.has('folding') == 1 then - vim.cmd('normal! zE') -- eliminate all folds - vim.opt_local.foldenable = false -- disable folding - end - end) - end + -- no `normal!` here: it drops the prompt out of insert mode + for _, win in ipairs(vim.fn.win_findbuf(bufnr)) do + vim.api.nvim_set_option_value('foldenable', false, { win = win, scope = 'local' }) end image.clear_buffer_images(bufnr) @@ -939,7 +930,7 @@ function M.scroll_to_line(line) M.state.scroll_offset = new_offset pcall(vim.api.nvim_win_call, M.state.winid, function() vim.api.nvim_win_set_cursor(M.state.winid, { target_line, 0 }) - vim.cmd('normal! zt') + vim.fn.winrestview({ topline = target_line }) end) end diff --git a/lua/fff/picker_ui/picker_ui.lua b/lua/fff/picker_ui/picker_ui.lua index cdb9f4e50..a107bb64e 100644 --- a/lua/fff/picker_ui/picker_ui.lua +++ b/lua/fff/picker_ui/picker_ui.lua @@ -191,7 +191,7 @@ local function restore_from_state(state, source_label) if M.state.active and M.state.input_win and vim.api.nvim_win_is_valid(M.state.input_win) then local prompt_len = #M.state.config.prompt vim.api.nvim_win_set_cursor(M.state.input_win, { 1, prompt_len + #state.query }) - vim.cmd('stopinsert') + ui_creator.stop_insert() end end) diff --git a/lua/fff/picker_ui/ui_creator.lua b/lua/fff/picker_ui/ui_creator.lua index 890d541d9..da6ce3940 100644 --- a/lua/fff/picker_ui/ui_creator.lua +++ b/lua/fff/picker_ui/ui_creator.lua @@ -150,6 +150,116 @@ local function move_list_cursor(direction) end end +local insert_guard_ns = vim.api.nvim_create_namespace('fff_picker_insert_guard') +-- vim.on_key can discard keys only since 0.11 +local can_hold_keys = vim.fn.has('nvim-0.11') == 1 +local insert_guard = nil + +-- Foreign autocmds running `windo` leave the prompt window, which makes neovim +-- stop insert mode. Re-enter it unless a key was typed since those autocmds ran. +local function setup_insert_mode_guard(group) + local guard = { key_tick = 0, held_keys = {}, generation = 0 } + insert_guard = guard + + local function finish_restore(replay) + local keys = table.concat(guard.held_keys) + guard.restoring = false + guard.held_keys = {} + if replay and keys ~= '' then vim.api.nvim_feedkeys(keys, 'mt', false) end + end + + local function restore_insert() + guard.scheduled = false + if not guard.restoring then return end + if not P.state.active or vim.api.nvim_get_current_win() ~= S.input_win then return finish_restore(false) end + if vim.api.nvim_get_mode().mode ~= 'n' then return end + + -- '^ is the exact position where insert mode was stopped + guard.col = guard.col or vim.api.nvim_buf_get_mark(S.input_buf, '^')[2] + local line = vim.api.nvim_buf_get_lines(S.input_buf, 0, 1, false)[1] or '' + if guard.col >= #line then + vim.cmd('startinsert!') + else + vim.api.nvim_win_set_cursor(S.input_win, { 1, guard.col }) + vim.cmd('startinsert') + end + end + + -- startinsert is ignored while the mode change is still in progress + local function schedule_restore() + if guard.scheduled then return end + guard.scheduled = true + vim.schedule(restore_insert) + end + + vim.on_key(function(_, typed) + if not P.state.active or insert_guard ~= guard then + if insert_guard == guard then vim.on_key(nil, insert_guard_ns) end + return + end + guard.key_tick = guard.key_tick + 1 + if not guard.restoring then return end + + if not can_hold_keys then return finish_restore(false) end + -- typed before insert mode is back, replayed once it is + if typed and typed ~= '' then + table.insert(guard.held_keys, typed) + return '' + end + end, insert_guard_ns) + + -- keys that only edited the query are not the ones leaving insert mode + vim.api.nvim_buf_attach(S.input_buf, false, { + on_lines = function() + if guard.armed_tick then guard.armed_tick = guard.key_tick end + end, + }) + + local events = { 'FileType', 'Syntax', 'OptionSet', 'BufReadPost', 'BufWinEnter', 'BufWinLeave', 'BufEnter', 'BufLeave' } + vim.api.nvim_create_autocmd(events, { + group = group, + callback = function() + if not P.state.active then return end + local mode = vim.api.nvim_get_mode().mode + if mode:sub(1, 1) == 'i' then + guard.armed_tick = guard.key_tick + elseif mode == 'n' and guard.restoring then + -- the pending startinsert may have been swallowed as well + schedule_restore() + end + end, + desc = 'Track autocmds that may kick the prompt out of insert mode', + }) + + vim.api.nvim_create_autocmd('ModeChanged', { + group = group, + pattern = { 'i:n', 'n:i' }, + callback = function() + if vim.v.event.new_mode == 'i' then + if guard.restoring then finish_restore(true) end + return + end + + local tick = guard.key_tick + local dropped = guard.armed_tick == tick and guard.keep_normal_tick ~= tick + guard.armed_tick = nil + if not dropped or not P.state.active then return end + + guard.restoring = true + guard.col = nil + guard.generation = guard.generation + 1 + schedule_restore() + + -- never keep holding keys if insert mode does not come back + local generation = guard.generation + vim.defer_fn(function() + if guard.restoring and guard.generation == generation then finish_restore(P.state.active) end + end, 500) + end, + desc = 'Restore insert mode dropped by foreign autocmds', + }) +end + function M.create_ui() local config = S.config if not config then return false end @@ -304,6 +414,8 @@ function M.setup_windows() desc = 'Close picker when focus leaves picker windows', }) + setup_insert_mode_guard(picker_group) + vim.api.nvim_create_autocmd('VimResized', { group = picker_group, callback = function() @@ -492,7 +604,7 @@ function M.focus_list_win() if not P.state.active then return end if not S.list_win or not vim.api.nvim_win_is_valid(S.list_win) then return end - vim.cmd('stopinsert') + M.stop_insert() vim.api.nvim_set_current_win(S.list_win) end @@ -500,7 +612,7 @@ function M.focus_preview_win() if not P.state.active then return end if not S.preview_win or not vim.api.nvim_win_is_valid(S.preview_win) then return end - vim.cmd('stopinsert') + M.stop_insert() vim.api.nvim_set_current_win(S.preview_win) end @@ -512,6 +624,12 @@ function M.focus_input_win() vim.api.nvim_win_call(S.input_win, function() vim.cmd('startinsert!') end) end +--- Leave insert mode on purpose, the insert mode guard will not undo it. +function M.stop_insert() + if insert_guard then insert_guard.keep_normal_tick = insert_guard.key_tick end + vim.cmd('stopinsert') +end + -- Expose open/close preview for relayout in the main module M.open_preview = open_preview M.close_preview = close_preview