feat(nvim): Jump to next file match segment (#595)

This commit is contained in:
Jesper Lindeberg
2026-06-17 22:00:04 +02:00
committed by GitHub
parent 5abdfb510b
commit 3b5e78c6b0
6 changed files with 261 additions and 18 deletions
+20 -18
View File
@@ -312,6 +312,9 @@ require('fff').setup({
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
cycle_grep_modes = '<S-Tab>',
-- grep mode only: jump cursor to first match of next/prev file group
grep_jump_to_next_file = { '<C-A-n>', '<A-Down>' },
grep_jump_to_prev_file = { '<C-A-p>', '<A-Up>' },
cycle_previous_query = '<C-Up>',
toggle_select = '<Tab>',
send_to_quickfix = '<C-q>',
@@ -451,20 +454,20 @@ disabled individually via `debug.show_file_info`.
Customise the panel via `hl`:
| key | default | used for |
| ---------------------------- | -------------------- | ----------------------------------- |
| `file_info_section` | `Title` | section header label |
| `file_info_separator` | `FloatBorder` | dashes that act as section borders |
| `file_info_label` | `Comment` | row labels (Size, Type, Git, ...) |
| `file_info_value` | `Normal` fg | plain values |
| `file_info_value_dim` | `NonText` | dim values, separators inside rows |
| `file_info_size` | `Number` | file size value |
| `file_info_type` | `Type` | filetype value |
| `file_info_path` | `Directory` | full path |
| `file_info_total_score` | bold + `Number` | total score (bold) |
| `file_info_match_type` | bold + `Special` | match type (bold) |
| `file_info_score_pos` | `DiagnosticOk` | positive score components |
| `file_info_score_neg` | `DiagnosticError` | negative score components |
| key | default | used for |
| ----------------------- | ----------------- | ---------------------------------- |
| `file_info_section` | `Title` | section header label |
| `file_info_separator` | `FloatBorder` | dashes that act as section borders |
| `file_info_label` | `Comment` | row labels (Size, Type, Git, ...) |
| `file_info_value` | `Normal` fg | plain values |
| `file_info_value_dim` | `NonText` | dim values, separators inside rows |
| `file_info_size` | `Number` | file size value |
| `file_info_type` | `Type` | filetype value |
| `file_info_path` | `Directory` | full path |
| `file_info_total_score` | bold + `Number` | total score (bold) |
| `file_info_match_type` | bold + `Special` | match type (bold) |
| `file_info_score_pos` | `DiagnosticOk` | positive score components |
| `file_info_score_neg` | `DiagnosticError` | negative score components |
### File filtering
@@ -482,7 +485,7 @@ Run `:FFFScan` to force a rescan.
- `:FFFHealth` verifies picker init, optional dependencies, and DB connectivity.
- `:FFFOpenLog` opens the current session's log file.
- Historical log files are stored near the main log file `<state>/log/fff+<UTC-timestamp>+<pid>.log` (up to 20 files)
- For a crash backtrace, run `lldb -- nvim` or `gdb -- nvim` and reproduce
- For a crash backtrace, run `lldb -- nvim` or `gdb -- nvim` and reproduce
</details>
@@ -634,7 +637,7 @@ FffResult *res = fff_create_instance_with(&(FffCreateOptions){
.version = FFF_CREATE_OPTIONS_VERSION,
.base_path = "/path/to/repo",
.ai_mode = true,
.watch = true,
.watch = true,
.enable_fs_root_scanning = false, // off by default
.enable_home_dir_scanning = false, // off by default
});
@@ -741,7 +744,7 @@ FFF is a file search library, not a CLI. Ripgrep and fzf are great tools, but th
FFF keeps the index and the file cache resident in one long-lived process and exposes the same Rust core through four thin layers: a native crate (`fff-search`), a C library (`libfff_c`), a Node/Bun SDK (`@ff-labs/fff-node`), and an MCP server. You call `FileFinder.create()` once, then every subsequent search hits warm memory. On a 500k-file Chromium checkout, that is the difference between 3-9 **SECONDS** per ripgrep spawn and sub-10 ms per FFF query.
Algorithm for fuzzy matching is much more comprehensive than fzf's algorithm it is **typo-resistant** and we provide a query language with additional constraint parsing for prefiltering e.g. "*.rs !test/ shcema" is a perfectly valid query for fff, but fzf wouldn't find anything even for a single typo in "shcema".
Algorithm for fuzzy matching is much more comprehensive than fzf's algorithm it is **typo-resistant** and we provide a query language with additional constraint parsing for prefiltering e.g. "\*.rs !test/ shcema" is a perfectly valid query for fff, but fzf wouldn't find anything even for a single typo in "shcema".
### Why a programmatic API matters
@@ -775,7 +778,6 @@ Algorithm for fuzzy matching is much more comprehensive than fzf's algorithm it
Yes, fff fundamentally requires more memory than calling a single child process. That is the primary source of the speedup. In practice, alongside one of the most popular file search pickers for Neovim, [fff ends up using less RAM than a burst of ripgrep invocations](https://x.com/neogoose_btw/status/2041606853155811442).
FFF also keeps a content index, around 360 bytes per indexed file, so roughly 36 MB for a 100k-file repo. Not every file is indexed - binaries, oversized files, and anything not eligible for grep are skipped. If even that footprint is too much, the index can be backed by a memory-mapped file instead of anonymous RAM.
### What this means in practice
+5
View File
@@ -35,6 +35,8 @@ local M = {}
--- @field cycle_grep_modes string
--- @field cycle_previous_query string
--- @field cycle_forward_query string
--- @field grep_jump_to_next_file string|string[]
--- @field grep_jump_to_prev_file string|string[]
--- @field toggle_select string
--- @field send_to_quickfix string
--- @field focus_list string
@@ -269,6 +271,9 @@ local function init()
toggle_debug = '<F2>',
-- grep mode: cycle between plain text, regex, and fuzzy search
cycle_grep_modes = '<S-Tab>',
-- grep mode only: jump cursor to first item of next/prev file group
grep_jump_to_next_file = { '<C-A-n>', '<A-Down>' },
grep_jump_to_prev_file = { '<C-A-p>', '<A-Up>' },
-- goes to the previous query in history
cycle_previous_query = '<C-Up>',
-- goes to the next query in history (forward)
+90
View File
@@ -196,4 +196,94 @@ function M.scroll_preview_down()
preview.scroll(scroll_lines)
end
-- Helper function to eliminate UI update redundancy
local function update_ui_after_jump(old_cursor)
if not P.render_after_cursor_move(old_cursor) then return false end
P.update_status()
pcall(vim.cmd, 'redraw')
P.update_preview_debounced()
return true
end
function M.grep_jump_to_next_file()
if not P.state.active or S.mode ~= 'grep' then return end
local items = S.filtered_items
if not items or #items == 0 then return end
local old_cursor = S.cursor
local current_path = items[S.cursor] and items[S.cursor].relative_path
for i = S.cursor + 1, #items do
if items[i].relative_path ~= current_path then
S.cursor = i
update_ui_after_jump(old_cursor)
return
end
end
if P.load_next_page and P.load_next_page() then
local new_items = S.filtered_items
if new_items and #new_items > 0 then
local idx = 1
if new_items[1].relative_path == current_path then
for i = 2, #new_items do
if new_items[i].relative_path ~= current_path then
idx = i
break
end
end
end
S.cursor = idx
update_ui_after_jump(old_cursor)
end
end
end
function M.grep_jump_to_prev_file()
if not P.state.active or S.mode ~= 'grep' then return end
local items = S.filtered_items
if not items or #items == 0 then return end
local old_cursor = S.cursor
local current_path = items[S.cursor] and items[S.cursor].relative_path
for i = S.cursor - 1, 1, -1 do
if items[i].relative_path ~= current_path then
local target_idx = i
while target_idx > 1 and items[target_idx - 1].relative_path == items[i].relative_path do
target_idx = target_idx - 1
end
S.cursor = target_idx
update_ui_after_jump(old_cursor)
return
end
end
if P.load_previous_page and P.load_previous_page() then
local new_items = S.filtered_items
if not new_items or #new_items == 0 then return end
local target_path = nil
for i = #new_items, 1, -1 do
if new_items[i].relative_path ~= current_path then
target_path = new_items[i].relative_path
break
end
end
target_path = target_path or new_items[#new_items].relative_path
local first = 1
for i = 1, #new_items do
if new_items[i].relative_path == target_path then
first = i
break
end
end
S.cursor = first
update_ui_after_jump(old_cursor)
end
end
return M
+2
View File
@@ -76,6 +76,8 @@ M.move_up = navigation.move_up
M.move_down = navigation.move_down
M.scroll_preview_up = navigation.scroll_preview_up
M.scroll_preview_down = navigation.scroll_preview_down
M.grep_jump_to_next_file = navigation.grep_jump_to_next_file
M.grep_jump_to_prev_file = navigation.grep_jump_to_prev_file
-- Expose helpers used by navigation
M.scroll_to_bottom = renderer.scroll_to_bottom
+7
View File
@@ -347,6 +347,13 @@ function M.setup_keymaps()
set_keymap('n', keymaps.focus_list, M.focus_list_win, input_opts)
set_keymap('n', keymaps.focus_preview, M.focus_preview_win, input_opts)
if keymaps.grep_jump_to_next_file then
set_keymap({ 'i', 'n' }, keymaps.grep_jump_to_next_file, function() P.grep_jump_to_next_file() end, input_opts)
end
if keymaps.grep_jump_to_prev_file then
set_keymap({ 'i', 'n' }, keymaps.grep_jump_to_prev_file, function() P.grep_jump_to_prev_file() end, input_opts)
end
if S.config.prompt_vim_mode then
set_keymap('n', keymaps.close, P.close, input_opts)
set_keymap('i', '<C-c>', P.close, input_opts)
+137
View File
@@ -0,0 +1,137 @@
---@diagnostic disable: undefined-field, missing-fields
-- Integration tests for grep mode file-group jump shortcuts.
local picker_ui
local state_mod
local plugin_dir = vim.fn.fnamemodify(vim.fn.resolve(debug.getinfo(1, 'S').source:sub(2)), ':h:h')
local function make_item(path, line, col)
return { relative_path = path, line_number = line, col = col, line_content = '' }
end
local function reset_state(items, cursor)
local S = state_mod.state
S.active = true
S.mode = 'grep'
S.filtered_items = items
S.items = items
S.cursor = cursor or 1
S.pagination = S.pagination or {}
S.pagination.page_size = #items
S.pagination.page_index = 0
S.pagination.total_matched = #items
S.pagination.grep_file_offsets = { 0 }
S.pagination.grep_next_file_offset = 0
end
local stubs_installed = false
local function install_stubs()
if stubs_installed then return end
-- Mock UI updates called by submodules
picker_ui.render_after_cursor_move = function() return true end
picker_ui.update_status = function() end
picker_ui.update_preview_debounced = function() end
stubs_installed = true
end
describe('grep_jump_to_next_file / grep_jump_to_prev_file', function()
before_each(function()
vim.g.fff = {}
local fff_rust = require('fff.rust')
local file_picker = require('fff.file_picker')
-- Initialize core components and background worker threads
file_picker.setup()
fff_rust.init_file_picker(plugin_dir)
-- Resolve to the correct coordinator path inside the submodules directory
picker_ui = require('fff.picker_ui.picker_ui')
state_mod = require('fff.picker_ui.picker_ui_state')
install_stubs()
end)
after_each(function()
local fff_rust = require('fff.rust')
pcall(fff_rust.stop_background_monitor)
pcall(fff_rust.cleanup_file_picker)
vim.g.fff = nil
end)
it('jumps to first match of the next file group', function()
local items = {
make_item('a.lua', 1, 1),
make_item('a.lua', 5, 3),
make_item('a.lua', 9, 1),
make_item('b.lua', 2, 1),
make_item('b.lua', 7, 1),
make_item('c.lua', 4, 2),
}
reset_state(items, 1)
picker_ui.grep_jump_to_next_file()
assert.are.equal(4, state_mod.state.cursor)
picker_ui.grep_jump_to_next_file()
assert.are.equal(6, state_mod.state.cursor)
end)
it('jumps to first match of the previous file group', function()
local items = {
make_item('a.lua', 1, 1),
make_item('a.lua', 5, 3),
make_item('b.lua', 2, 1),
make_item('b.lua', 7, 1),
make_item('c.lua', 4, 2),
}
reset_state(items, 5)
picker_ui.grep_jump_to_prev_file()
assert.are.equal(3, state_mod.state.cursor)
picker_ui.grep_jump_to_prev_file()
assert.are.equal(1, state_mod.state.cursor)
end)
it('is a no-op when not in grep mode', function()
local items = { make_item('a.lua', 1, 1), make_item('b.lua', 1, 1) }
reset_state(items, 1)
state_mod.state.mode = nil
picker_ui.grep_jump_to_next_file()
assert.are.equal(1, state_mod.state.cursor)
end)
it('loads next page when no later file group exists on current page', function()
local page1 = {
make_item('a.lua', 1, 1),
make_item('a.lua', 2, 1),
}
local page2 = {
make_item('b.lua', 1, 1),
make_item('b.lua', 4, 1),
}
reset_state(page1, 2)
state_mod.state.pagination.grep_next_file_offset = 1
local called = false
local original_load_next = picker_ui.load_next_page
picker_ui.load_next_page = function()
called = true
state_mod.state.filtered_items = page2
state_mod.state.items = page2
state_mod.state.cursor = 1
state_mod.state.pagination.page_index = 1
return true
end
picker_ui.grep_jump_to_next_file()
assert.is_true(called, 'expected load_next_page to be invoked')
assert.are.equal(1, state_mod.state.cursor)
assert.are.equal('b.lua', state_mod.state.filtered_items[state_mod.state.cursor].relative_path)
picker_ui.load_next_page = original_load_next
end)
end)