Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,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>',
Expand Down
5 changes: 5 additions & 0 deletions lua/fff/conf.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -256,6 +258,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)
Expand Down
94 changes: 94 additions & 0 deletions lua/fff/picker_ui.lua
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,12 @@ function M.setup_keymaps()
set_keymap({ 'i', 'n' }, keymaps.toggle_select, M.toggle_select, input_opts)
set_keymap({ 'i', 'n' }, keymaps.send_to_quickfix, M.send_to_quickfix, input_opts)
set_keymap({ 'i', 'n' }, keymaps.cycle_grep_modes, M.cycle_grep_modes, input_opts)
if keymaps.grep_jump_to_next_file then
set_keymap({ 'i', 'n' }, keymaps.grep_jump_to_next_file, M.grep_jump_to_next_file, input_opts)
end
if keymaps.grep_jump_to_prev_file then
set_keymap({ 'i', 'n' }, keymaps.grep_jump_to_prev_file, M.grep_jump_to_prev_file, input_opts)
end

-- List buffer
set_keymap('n', keymaps.close, M.close, list_opts)
Expand Down Expand Up @@ -581,6 +587,94 @@ end
--- Cycle through grep search modes based on configured modes list.
--- Only works when the picker is in grep mode. Triggers a re-search
--- with the current query using the new mode.
--- Jump cursor to first item of the next file group in grep mode.
--- Loads next page when current page ends without a new file group.
function M.grep_jump_to_next_file()
if not M.state.active or M.state.mode ~= 'grep' then return end
local items = M.state.filtered_items
if not items or #items == 0 then return end

local current_path = items[M.state.cursor] and items[M.state.cursor].relative_path
for i = M.state.cursor + 1, #items do
if items[i].relative_path ~= current_path then
M.state.cursor = i
M.render_list()
M.update_preview_smart()
M.update_status()
return
end
end

if M.load_next_page() then
local new_items = M.state.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
M.state.cursor = idx
M.render_list()
M.update_preview_smart()
M.update_status()
end
end
end

--- Jump cursor to first item of the previous file group in grep mode.
--- Loads previous page when current page starts without an earlier group.
function M.grep_jump_to_prev_file()
if not M.state.active or M.state.mode ~= 'grep' then return end
local items = M.state.filtered_items
if not items or #items == 0 then return end

local current_path = items[M.state.cursor] and items[M.state.cursor].relative_path

local prev_path = nil
for i = M.state.cursor - 1, 1, -1 do
if items[i].relative_path ~= current_path then
prev_path = items[i].relative_path
break
end
end
if prev_path then
local first = 1
for i = 1, #items do
if items[i].relative_path == prev_path then
first = i
break
end
end
M.state.cursor = first
M.render_list()
M.update_preview_smart()
M.update_status()
return
end

if M.load_previous_page() then
local new_items = M.state.filtered_items
if new_items and #new_items > 0 then
local last_path = new_items[#new_items].relative_path
local first = #new_items
for i = 1, #new_items do
if new_items[i].relative_path == last_path then
first = i
break
end
end
M.state.cursor = first
M.render_list()
M.update_preview_smart()
M.update_status()
end
end
end

function M.cycle_grep_modes()
if not M.state.active or M.state.mode ~= 'grep' then return end

Expand Down
110 changes: 110 additions & 0 deletions tests/grep_jump_file_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---@diagnostic disable: undefined-field
-- Tests for grep mode file-group jump shortcuts (issue #512).
-- Validates grep_jump_to_next_file / grep_jump_to_prev_file move the cursor
-- to the first item of the adjacent file group and trigger page loading
-- when the group boundary lies on the next/previous page.

local picker_ui = require('fff.picker_ui')

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)
picker_ui.state.active = true
picker_ui.state.mode = 'grep'
picker_ui.state.filtered_items = items
picker_ui.state.items = items
picker_ui.state.cursor = cursor or 1
picker_ui.state.pagination = picker_ui.state.pagination or {}
picker_ui.state.pagination.page_size = #items
picker_ui.state.pagination.page_index = 0
picker_ui.state.pagination.total_matched = #items
picker_ui.state.pagination.grep_file_offsets = { 0 }
picker_ui.state.pagination.grep_next_file_offset = 0
end

local stubs_installed = false
local function install_stubs()
if stubs_installed then return end
picker_ui.render_list = function() end
picker_ui.update_preview_smart = function() end
picker_ui.update_preview = function() end
picker_ui.update_status = function() end
stubs_installed = true
end

describe('grep_jump_to_next_file / grep_jump_to_prev_file', function()
before_each(function() install_stubs() 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, picker_ui.state.cursor)
picker_ui.grep_jump_to_next_file()
assert.are.equal(6, picker_ui.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, picker_ui.state.cursor)
picker_ui.grep_jump_to_prev_file()
assert.are.equal(1, picker_ui.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)
picker_ui.state.mode = nil
picker_ui.grep_jump_to_next_file()
assert.are.equal(1, picker_ui.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)
-- Pretend more pages are available.
picker_ui.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
picker_ui.state.filtered_items = page2
picker_ui.state.items = page2
picker_ui.state.cursor = 1
picker_ui.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, picker_ui.state.cursor)
assert.are.equal('b.lua', picker_ui.state.filtered_items[picker_ui.state.cursor].relative_path)

picker_ui.load_next_page = original_load_next
end)
end)
Loading