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
105 changes: 81 additions & 24 deletions lua/fff/picker_ui.lua
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,8 @@ M.state = {
preview_debounce_ms = 100, -- Preview is more expensive, debounce more

-- Set of selected file paths: { [filepath] = true }
-- Uses Set pattern: selected items exist as keys with value true, deselected items are removed (nil)
-- This allows O(1) lookup and automatic deduplication without needing to filter false values
selected_files = {},
selected_file_order = {},

-- Grep mode: per-occurrence selection keyed by "path:line:col"
-- Values are the full item tables so quickfix can be built from selections alone (survives page changes)
Expand Down Expand Up @@ -547,6 +546,7 @@ function M.toggle_debug()
local current_grep_config = M.state.grep_config
local current_filtered_items = M.state.filtered_items
local current_selected_files = M.state.selected_files
local current_selected_file_order = M.state.selected_file_order
local current_selected_items = M.state.selected_items

M.close()
Expand All @@ -562,6 +562,7 @@ function M.toggle_debug()
M.state.grep_mode = current_grep_mode
M.state.filtered_items = current_filtered_items
M.state.selected_files = current_selected_files
M.state.selected_file_order = current_selected_file_order
M.state.selected_items = current_selected_items
M.render_list()
M.update_preview()
Expand Down Expand Up @@ -1880,6 +1881,64 @@ local function find_suitable_window()
return utils.find_suitable_window(exclude)
end

local function edit_file_in_target_window(relative_path)
local current_win = vim.api.nvim_get_current_win()
local current_buf = vim.api.nvim_get_current_buf()
local current_buftype = vim.api.nvim_get_option_value('buftype', { buf = current_buf })
local current_buf_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = current_buf })
local current_winfixbuf = window_has_winfixbuf(current_win)
local opened_via_split = false

if current_buftype ~= '' or not current_buf_modifiable or current_winfixbuf then
local suitable_win = find_suitable_window()
if suitable_win then
vim.api.nvim_set_current_win(suitable_win)
elseif current_winfixbuf then
vim.cmd('split ' .. vim.fn.fnameescape(relative_path))
opened_via_split = true
end
end

if not opened_via_split then vim.cmd('edit ' .. vim.fn.fnameescape(relative_path)) end
end

local function remove_selected_file_order(relative_path)
M.state.selected_file_order = vim.tbl_filter(
function(path) return path ~= relative_path end,
M.state.selected_file_order
)
end

local function get_selected_file_entries()
if not next(M.state.selected_files) then return {} end

local entries = {}
local seen = {}

local function add(relative_path)
if not relative_path or seen[relative_path] or not M.state.selected_files[relative_path] then return end

local abs_path = canonicalize_fff_path(relative_path)
if not abs_path then return end

seen[relative_path] = true
table.insert(entries, {
relative_path = relative_path,
edit_path = vim.fn.fnamemodify(abs_path, ':.'),
})
end

for _, relative_path in ipairs(M.state.selected_file_order) do
add(relative_path)
end

for relative_path, _ in pairs(M.state.selected_files) do
add(relative_path)
end

return entries
end

--- Build a unique key for a grep match occurrence.
--- Format: "path:line:col" — uniquely identifies one match entry.
---@param item table Grep match item with path, line_number, col
Expand Down Expand Up @@ -1917,8 +1976,10 @@ function M.toggle_select()
was_selected = M.state.selected_files[item.relative_path]
if was_selected then
M.state.selected_files[item.relative_path] = nil
remove_selected_file_order(item.relative_path)
else
M.state.selected_files[item.relative_path] = true
table.insert(M.state.selected_file_order, item.relative_path)
end
end

Expand Down Expand Up @@ -2053,6 +2114,9 @@ function M.select(action)

-- In grep mode (or when selecting a grep suggestion), derive location from the match item
local is_grep_item = mode == 'grep' or suggestion_source == 'grep'
local selected_file_entries = {}
if action == 'edit' and not is_grep_item then selected_file_entries = get_selected_file_entries() end

if is_grep_item and item.line_number and item.line_number > 0 then
location = { line = item.line_number }
if item.col and item.col > 0 then
Expand All @@ -2077,35 +2141,22 @@ function M.select(action)
end
end

if #selected_file_entries > 0 and selected_file_entries[1].relative_path ~= item.relative_path then location = nil end

vim.cmd('stopinsert')
M.close()

-- Defer file open past picker float teardown. Without this, foldexpr is not
-- recomputed on the new window (folds appear missing) on some platforms.
vim.schedule(function()
if action == 'edit' then
local current_win = vim.api.nvim_get_current_win()
local current_buf = vim.api.nvim_get_current_buf()
local current_buftype = vim.api.nvim_get_option_value('buftype', { buf = current_buf })
local current_buf_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = current_buf })
local current_winfixbuf = window_has_winfixbuf(current_win)

-- If the current window can't host a new buffer (special buftype, non-modifiable,
-- or 'winfixbuf' locking it), retarget a suitable window or fall back to a split.
-- Without this, :edit raises E1513 ("Cannot switch buffer. 'winfixbuf' is enabled")
-- whenever the picker is invoked from a window pinned via :h winfixbuf.
local opened_via_split = false
if current_buftype ~= '' or not current_buf_modifiable or current_winfixbuf then
local suitable_win = find_suitable_window()
if suitable_win then
vim.api.nvim_set_current_win(suitable_win)
elseif current_winfixbuf then
vim.cmd('split ' .. vim.fn.fnameescape(relative_path))
opened_via_split = true
end
if action == 'edit' and #selected_file_entries > 0 then
for _, entry in ipairs(selected_file_entries) do
local buf = vim.fn.bufadd(entry.edit_path)
vim.bo[buf].buflisted = true
end

if not opened_via_split then vim.cmd('edit ' .. vim.fn.fnameescape(relative_path)) end
edit_file_in_target_window(selected_file_entries[1].edit_path)
elseif action == 'edit' then
edit_file_in_target_window(relative_path)
elseif action == 'split' then
vim.cmd('split ' .. vim.fn.fnameescape(relative_path))
elseif action == 'vsplit' then
Expand All @@ -2123,6 +2174,10 @@ function M.select(action)
-- Track in background thread (non-blocking, handled by Rust)
if mode == 'grep' then
pcall(fff.track_grep_query, query)
elseif #selected_file_entries > 0 then
for _, entry in ipairs(selected_file_entries) do
pcall(fff.track_query_completion, query, entry.relative_path)
end
else
pcall(fff.track_query_completion, query, item.relative_path)
end
Expand Down Expand Up @@ -2262,6 +2317,7 @@ function M.close()
M.state.current_file_cache = nil
M.state.location = nil
M.state.selected_files = {}
M.state.selected_file_order = {}
M.state.selected_items = {}
M.state.mode = nil
M.state.grep_config = nil
Expand Down Expand Up @@ -2431,6 +2487,7 @@ function M.open(opts)
if M.state.active then return end

M.state.selected_files = {}
M.state.selected_file_order = {}
M.state.selected_items = {}
M.state.renderer = opts and opts.renderer or nil
M.state.mode = opts and opts.mode or nil
Expand Down
53 changes: 50 additions & 3 deletions tests/picker_dir_resolution_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ local function wait_for_scan(expected_dir, timeout_ms)
end

describe('picker find_files_in_dir path resolution (issue #389)', function()
local sandbox_root, target_dir, other_cwd, target_filename
local sandbox_root, target_dir, other_cwd, target_filename, second_filename

before_each(function()
sandbox_root = vim.fn.tempname()
Expand All @@ -61,9 +61,14 @@ describe('picker find_files_in_dir path resolution (issue #389)', function()
vim.fn.mkdir(target_dir, 'p')
vim.fn.mkdir(other_cwd, 'p')

target_filename = 'issue389_target.lua'
target_filename = 'issue389_target.txt'
local fd = assert(io.open(target_dir .. '/' .. target_filename, 'w'))
fd:write('-- issue #389 regression fixture\nreturn true\n')
fd:write('issue #389 regression fixture\n')
fd:close()

second_filename = 'issue389_second.txt'
fd = assert(io.open(target_dir .. '/' .. second_filename, 'w'))
fd:write('issue #389 second fixture\n')
fd:close()

-- Clear the DirChanged autocmd that a previous test run (e.g. fff_core_spec)
Expand Down Expand Up @@ -127,6 +132,7 @@ describe('picker find_files_in_dir path resolution (issue #389)', function()
picker_ui.state.location = nil
picker_ui.state.suggestion_source = nil
picker_ui.state.selected_files = {}
picker_ui.state.selected_file_order = {}
picker_ui.state.selected_items = {}

picker_ui.select('edit')
Expand All @@ -147,4 +153,45 @@ describe('picker find_files_in_dir path resolution (issue #389)', function()
local actual = norm(bufname)
assert.are.equal(expected, actual)
end)

it(':edit loads all selected files as listed buffers', function()
assert.is_true(require('fff.core').change_indexing_directory(target_dir))
wait_for_scan(target_dir, 10000)

local items = file_picker.search_files('', nil, nil, nil, nil)
local selected = {}
for _, item in ipairs(items) do
if item.name == target_filename or item.name == second_filename then table.insert(selected, item) end
end
assert.are.equal(2, #selected, 'expected both fixture files in picker results')

picker_ui.state.active = true
picker_ui.state.filtered_items = items
picker_ui.state.cursor = 1
picker_ui.state.query = ''
picker_ui.state.mode = nil
picker_ui.state.location = nil
picker_ui.state.suggestion_source = nil
picker_ui.state.selected_files = {}
picker_ui.state.selected_file_order = {}
picker_ui.state.selected_items = {}

for _, item in ipairs(selected) do
picker_ui.state.selected_files[item.relative_path] = true
table.insert(picker_ui.state.selected_file_order, item.relative_path)
end

picker_ui.select('edit')

local expected_first = norm(target_dir .. '/' .. selected[1].name)
vim.wait(2000, function() return norm(vim.api.nvim_buf_get_name(0)) == expected_first end)
assert.are.equal(expected_first, norm(vim.api.nvim_buf_get_name(0)))

for _, item in ipairs(selected) do
local path = norm(target_dir .. '/' .. item.name)
local bufnr = vim.fn.bufnr(path)
assert.is_true(bufnr > 0, 'expected selected file to have a buffer: ' .. path)
assert.are.equal(1, vim.fn.buflisted(bufnr), 'expected selected file to be listed: ' .. path)
end
end)
end)
Loading