Skip to content
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion doc/fff.nvim.txt
Original file line number Diff line number Diff line change
@@ -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*
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -178,14 +179,18 @@ 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' })
<


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
Expand All @@ -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.
Expand Down
43 changes: 36 additions & 7 deletions lua/fff/main.lua
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,43 @@ 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
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_live_grep(cleaned)
return
end

local config = require('fff.conf').get()
local grep_renderer = require('fff.grep.grep_renderer')

Expand Down Expand Up @@ -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
Expand Down
203 changes: 199 additions & 4 deletions lua/fff/picker_ui.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {}
Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions plugin/fff.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down