diff --git a/README.md b/README.md index be44aa16..d54391d9 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb lazy = false, -- the plugin lazy-initialises itself keys = { { "ff", function() require('fff').find_files() end, desc = 'FFFind files' }, + { "fr", function() require('fff').resume() end, desc = 'FFFind resume' }, { "fg", function() require('fff').live_grep() end, desc = 'LiFFFe grep' }, { "fz", function() require('fff').live_grep({ grep = { modes = { 'fuzzy', 'plain' } } }) end, @@ -157,13 +158,17 @@ vim.g.fff = { } vim.keymap.set('n', 'ff', function() require('fff').find_files() end, { desc = 'FFFind files' }) +vim.keymap.set('n', 'fr', function() require('fff').resume() end, { desc = 'FFFind resume' }) ``` ### Public API ```lua require('fff').find_files() -- find files in current repo +require('fff').find_files({ resume = true }) -- resume last find_files picker require('fff').live_grep() -- live content grep +require('fff').live_grep({ resume = true }) -- resume last live_grep picker +require('fff').resume() -- resume last closed picker (files or grep) require('fff').scan_files() -- force rescan require('fff').refresh_git_status() -- refresh git status require('fff').find_files_in_dir(path) -- find in a specific dir @@ -172,6 +177,7 @@ require('fff').change_indexing_directory(new_path) -- change root ### Commands +- `:FFFResume`. Resume the last closed picker (restores query, results, cursor, and mode). - `:FFFScan`. Rescan files. - `:FFFRefreshGit`. Refresh git status. - `:FFFClearCache [all|frecency|files]`. Clear caches. diff --git a/doc/fff.nvim.txt b/doc/fff.nvim.txt index 4d70e70f..fdcd30bb 100644 --- a/doc/fff.nvim.txt +++ b/doc/fff.nvim.txt @@ -1,5 +1,5 @@ *fff.nvim.txt* - For Neovim >= 0.10.0 Last change: 2026 May 04 + For Neovim >= 0.10.0 Last change: 2026 May 15 ============================================================================== Table of Contents *fff.nvim-table-of-contents* @@ -143,6 +143,7 @@ LAZY.NVIM lazy = false, -- the plugin lazy-initialises itself keys = { { "ff", function() require('fff').find_files() end, desc = 'FFFind files' }, + { "fr", function() require('fff').resume() end, desc = 'FFFind resume' }, { "fg", function() require('fff').live_grep() end, desc = 'LiFFFe grep' }, { "fz", function() require('fff').live_grep({ grep = { modes = { 'fuzzy', 'plain' } } }) end, @@ -178,6 +179,7 @@ VIM.PACK } vim.keymap.set('n', 'ff', function() require('fff').find_files() end, { desc = 'FFFind files' }) + vim.keymap.set('n', 'fr', function() require('fff').resume() end, { desc = 'FFFind resume' }) < @@ -185,7 +187,10 @@ PUBLIC API ~ >lua require('fff').find_files() -- find files in current repo + require('fff').find_files({ resume = true }) -- resume last find_files picker require('fff').live_grep() -- live content grep + require('fff').live_grep({ resume = true }) -- resume last live_grep picker + require('fff').resume() -- resume last closed picker (files or grep) require('fff').scan_files() -- force rescan require('fff').refresh_git_status() -- refresh git status require('fff').find_files_in_dir(path) -- find in a specific dir @@ -195,6 +200,7 @@ PUBLIC API ~ COMMANDS ~ +- `:FFFResume`. Resume the last closed picker (restores query, results, cursor, and mode). - `:FFFScan`. Rescan files. - `:FFFRefreshGit`. Refresh git status. - `:FFFClearCache [all|frecency|files]`. Clear caches. diff --git a/lua/fff/main.lua b/lua/fff/main.lua index 92d9a746..e44a7b4b 100644 --- a/lua/fff/main.lua +++ b/lua/fff/main.lua @@ -6,19 +6,29 @@ M.state = { initialized = false } --- @param config table Configuration options function M.setup(config) vim.g.fff = config end ---- Find files in current directory ---- @param opts? table Optional configuration {renderer = custom_renderer} +--- Find files in current directory. +--- When opts.resume is true, resumes the last find_files picker (or opens a new one if none saved). +--- @param opts? table Optional configuration {renderer = custom_renderer, resume = boolean} function M.find_files(opts) local picker_ok, picker_ui = pcall(require, 'fff.picker_ui') - if picker_ok then - picker_ui.open(opts) - else + if not picker_ok then vim.notify('Failed to load picker UI: ' .. picker_ui, vim.log.levels.ERROR) + return end + + if opts and opts.resume then + local cleaned = vim.deepcopy(opts) + cleaned.resume = nil + picker_ui.resume_find_files(cleaned) + return + end + + picker_ui.open(opts) end ---- Live grep: search file contents in the current directory ---- @param opts? {cwd?: string, title?: string, prompt?: string, layout?: table, grep?: {max_file_size?: number, smart_case?: boolean, max_matches_per_file?: number, modes?: string[]}, query?: string} Optional configuration overrides +--- Live grep: search file contents in the current directory. +--- When opts.resume is true, resumes the last live_grep picker (or opens a new one if none saved). +--- @param opts? {cwd?: string, title?: string, prompt?: string, layout?: table, grep?: {max_file_size?: number, smart_case?: boolean, max_matches_per_file?: number, modes?: string[]}, query?: string, resume?: boolean} Optional configuration overrides function M.live_grep(opts) local picker_ok, picker_ui = pcall(require, 'fff.picker_ui') if not picker_ok then @@ -26,6 +36,13 @@ function M.live_grep(opts) return end + if opts and opts.resume then + local cleaned = vim.deepcopy(opts) + cleaned.resume = nil + picker_ui.resume_live_grep(cleaned) + return + end + local config = require('fff.conf').get() local grep_renderer = require('fff.grep.grep_renderer') @@ -215,6 +232,18 @@ function M.change_indexing_directory(new_path) return false end +--- Resume the most recently closed picker (find_files or live_grep). +--- Similar to Telescope's `require('telescope.builtin').resume()`. +---@return boolean true if a picker was resumed, false if there is nothing to resume +function M.resume() + local picker_ok, picker_ui = pcall(require, 'fff.picker_ui') + if not picker_ok then + vim.notify('Failed to load picker UI: ' .. picker_ui, vim.log.levels.ERROR) + return false + end + return picker_ui.resume() +end + --- Opens the file under the cursor with an optional callback if the only file --- is found and we are about to inline open it --- @param open_cb function|nil Optional callback function to execute after opening the file diff --git a/lua/fff/picker_ui.lua b/lua/fff/picker_ui.lua index 94635d3f..c81a7f04 100644 --- a/lua/fff/picker_ui.lua +++ b/lua/fff/picker_ui.lua @@ -10,6 +10,16 @@ local list_renderer = require('fff.list_renderer') local scrollbar = require('fff.scrollbar') local rust = require('fff.rust') +--- Saved state from the last closed file picker, used by resume(). +--- Populated in close() just before state is cleared. +local last_file_picker_state = nil +--- Saved state from the last closed grep picker, used by resume(). +--- Populated in close() just before state is cleared. +local last_grep_picker_state = nil +--- Tracks which mode was closed most recently ('files' or 'grep'). +--- Used by resume() to pick which state to restore. +local last_closed_mode = nil + --- Base path of picker can change that's why we can not rely on relative --- path for reading/opening files. This function resolves correct absolute path --- @param relative_path string|nil @@ -2599,12 +2609,194 @@ function M.relayout() M.update_status() end -function M.close() +--- Save the current picker state for later resume, then close. +local function save_state_and_close() + if M.state.query == '' then + -- Don't save empty state to avoid confusion on resume + M.close_windows() + return + end + if not M.state.active then return end + + -- Deep copy the full state to capture all data fields automatically (future-proof). + -- Window/buffer handles are also copied but are ignored during restore since UI is recreated. + local snapshot = vim.deepcopy(M.state) + + -- Capture the base_path from the Rust file indexer (not part of M.state) + local fuzzy = require('fff.core').ensure_initialized() + local ok, base_path = pcall(fuzzy.get_base_path) + if ok and base_path then + snapshot.base_path = base_path + else + snapshot.base_path = M.state.config and M.state.config.base_path or nil + end + + -- Save to the mode-specific slot + if M.state.mode == 'grep' then + last_grep_picker_state = snapshot + last_closed_mode = 'grep' + else + last_file_picker_state = snapshot + last_closed_mode = 'files' + end + + M.close_windows() +end + +--- Internal: restore picker from a saved state snapshot. +---@param state table The saved state table +---@param source_label string Label for error messages +---@return boolean +local function restore_from_state(state, source_label) + -- Ensure the file picker is initialized + if not file_picker.is_initialized() then + if not file_picker.setup() then + vim.notify('Failed to initialize file picker', vim.log.levels.ERROR) + return false + end + end + + -- Restore the picker with the saved config and mode + M.state.renderer = state.renderer + M.state.mode = state.mode + M.state.grep_config = state.grep_config + M.state.grep_mode = state.grep_mode + M.state.selected_files = vim.deepcopy(state.selected_files or {}) + M.state.selected_items = vim.deepcopy(state.selected_items or {}) + + -- Restore the saved base_path for the indexer if it differs from the current CWD + if state.base_path then M.change_indexing_directory(state.base_path) end + + -- Use the saved config directly to restore the exact picker state + M.state.config = state.config + + if not M.create_ui() then + vim.notify('FFF: failed to create picker UI for ' .. source_label, vim.log.levels.ERROR) + return false + end + + M.state.active = true + M.state.current_file_cache = state.current_file_cache + + -- Restore the full picker state + M.state.query = state.query + M.state.items = state.items or {} + M.state.filtered_items = state.filtered_items or {} + M.state.cursor = math.min(state.cursor or 1, #(state.filtered_items or {})) + M.state.cursor = math.max(M.state.cursor, 1) + M.state.location = state.location + M.state.pagination = vim.deepcopy(state.pagination or { + page_index = 0, + page_size = 20, + total_matched = 0, + prefetch_margin = 5, + grep_file_offsets = {}, + grep_next_file_offset = 0, + }) + M.state.combo_visible = state.combo_visible ~= false + M.state.combo_initial_cursor = state.combo_initial_cursor + M.state.suggestion_items = state.suggestion_items + M.state.suggestion_source = state.suggestion_source + + -- Set the query text in the input buffer + if state.query and state.query ~= '' then + vim.api.nvim_buf_set_lines(M.state.input_buf, 0, -1, false, { M.state.config.prompt .. state.query }) + end + + -- Render the restored state + M.render_list() + M.update_preview() + M.update_status() + + vim.api.nvim_set_current_win(M.state.input_win) + + -- Position cursor at end of query + vim.schedule(function() + 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('startinsert!') + end + end) + + -- Scan already completed when the original picker was open, no need to monitor again + return true +end + +---@return boolean true if a picker was resumed, false otherwise +function M.resume() + if M.state.active then + vim.notify('FFF: close the current picker before resuming', vim.log.levels.INFO) + return false + end + + -- Pick the most recently closed mode + if last_closed_mode == 'grep' then + return M.resume_live_grep() + elseif last_closed_mode == 'files' then + return M.resume_find_files() + end + + -- Fallback: try grep state, then file state, then open an empty find_files picker + if last_grep_picker_state then return restore_from_state(last_grep_picker_state, 'grep resume') end + if last_file_picker_state then return restore_from_state(last_file_picker_state, 'files resume') end + + -- Nothing saved: open an empty find_files picker + return M.open() +end + +--- Resume the last file picker (find_files mode). +--- Falls back to opening a new find_files picker if nothing to resume. +---@param opts? table Optional config overrides for fallback open +---@return boolean +function M.resume_find_files(opts) + if M.state.active then + vim.notify('FFF: close the current picker before resuming', vim.log.levels.INFO) + return false + end + + if not last_file_picker_state then + -- Nothing saved: open a new find_files picker + return M.open(opts) + end + + return restore_from_state(last_file_picker_state, 'find_files resume') +end + +--- Resume the last live_grep picker. +--- Falls back to opening a new live_grep picker if nothing to resume. +---@param opts? table Optional config overrides for fallback open +---@return boolean +function M.resume_live_grep(opts) + if M.state.active then + vim.notify('FFF: close the current picker before resuming', vim.log.levels.INFO) + return false + end + + if not last_grep_picker_state then + -- Nothing saved: open a new live_grep picker + local config = conf.get() + local grep_renderer = require('fff.grep.grep_renderer') + local grep_config = vim.tbl_deep_extend('force', config.grep or {}, (opts and opts.grep) or {}) + M.open(vim.tbl_deep_extend('force', { + mode = 'grep', + renderer = grep_renderer, + grep_config = grep_config, + title = 'Live Grep', + }, opts or {})) + return true + end + + return restore_from_state(last_grep_picker_state, 'live_grep resume') +end + +function M.close_windows() if not M.state.active then return end - vim.cmd('stopinsert') M.state.active = false + vim.cmd('stopinsert') + restore_paste(M.state.restore_paste) combo_renderer.cleanup() @@ -2682,6 +2874,8 @@ function M.close() pcall(vim.api.nvim_del_augroup_by_name, 'fff_picker_focus') end +function M.close() save_state_and_close() end + --- Helper function to determine current file cache for deprioritization --- @param base_path string|nil Base path for relative path calculation --- @return string|nil Current file cache path @@ -2823,8 +3017,9 @@ end --- Open the file picker UI --- @param opts? {cwd?: string, title?: string, prompt?: string, max_results?: number, max_threads?: number, layout?: {width?: number|function, height?: number|function, prompt_position?: string|function, preview_position?: string|function, preview_size?: number|function}, renderer?: table, mode?: string, grep_config?: table, query?: string} Optional configuration to override defaults +---@return boolean true if the picker was opened, false if it was already active or initialization failed function M.open(opts) - if M.state.active then return end + if M.state.active then return false end M.state.selected_files = {} M.state.selected_items = {} @@ -2833,7 +3028,7 @@ function M.open(opts) M.state.grep_config = opts and opts.grep_config or nil local merged_config, base_path = initialize_picker(opts) - if not merged_config then return end + if not merged_config then return false end if base_path then M.change_indexing_directory(base_path) end diff --git a/plugin/fff.lua b/plugin/fff.lua index 053017ca..abd2f42a 100644 --- a/plugin/fff.lua +++ b/plugin/fff.lua @@ -22,6 +22,10 @@ else }) end +vim.api.nvim_create_user_command('FFFResume', function() require('fff').resume() end, { + desc = 'Resume the last FFF picker (restores query, results, cursor, and mode)', +}) + vim.api.nvim_create_user_command('FFFFind', function(opts) local fff = require('fff') if opts.args and opts.args ~= '' then