feat(nvim): Jump to next file match segment (#595)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user