diff --git a/lua/fff/picker_ui/layout_manager.lua b/lua/fff/picker_ui/layout_manager.lua index 541dcc4a..1cddeab8 100644 --- a/lua/fff/picker_ui/layout_manager.lua +++ b/lua/fff/picker_ui/layout_manager.lua @@ -131,6 +131,7 @@ function M.close() S.current_file_cache = nil S.location = nil S.selected_files = {} + S.selected_file_order = {} S.selected_items = {} S.mode = nil S.grep_config = nil diff --git a/lua/fff/picker_ui/picker_ui.lua b/lua/fff/picker_ui/picker_ui.lua index 49ceeba0..1731dad7 100644 --- a/lua/fff/picker_ui/picker_ui.lua +++ b/lua/fff/picker_ui/picker_ui.lua @@ -133,6 +133,7 @@ local function restore_from_state(state, source_label) 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_file_order = vim.deepcopy(state.selected_file_order 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 @@ -266,6 +267,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() @@ -281,6 +283,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() @@ -461,6 +464,12 @@ 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' + + -- When opening with selections active, open every selected file. The first + -- selected file is focused; the rest are added as listed buffers. + local selected_file_entries = {} + if action == 'edit' and not is_grep_item then selected_file_entries = picker_ui_state.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 @@ -485,6 +494,10 @@ function M.select(action) end end + -- The focused file is the first selection, which may differ from the cursor + -- item; a cursor-derived location no longer applies, so drop it. + if #selected_file_entries > 0 and selected_file_entries[1].relative_path ~= item.relative_path then location = nil end + vim.cmd('stopinsert') M.close() @@ -514,15 +527,23 @@ function M.select(action) end if action == 'edit' then + -- Add every additional selection as a listed buffer before focusing one. + for _, entry in ipairs(selected_file_entries) do + local buf = vim.fn.bufadd(entry.edit_path) + vim.bo[buf].buflisted = true + end + + local edit_path = #selected_file_entries > 0 and selected_file_entries[1].edit_path or relative_path + -- Hard guard against E1513 ("Cannot switch buffer. 'winfixbuf' is enabled"): -- if the (post-hook) current window is pinned, fall back to :split. local opened_via_split = false if window_has_winfixbuf(vim.api.nvim_get_current_win()) then - vim.cmd('split ' .. vim.fn.fnameescape(relative_path)) + vim.cmd('split ' .. vim.fn.fnameescape(edit_path)) opened_via_split = true end - if not opened_via_split then vim.cmd('edit ' .. vim.fn.fnameescape(relative_path)) end + if not opened_via_split then vim.cmd('edit ' .. vim.fn.fnameescape(edit_path)) end elseif action == 'split' then vim.cmd('split ' .. vim.fn.fnameescape(relative_path)) elseif action == 'vsplit' then @@ -541,6 +562,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 @@ -700,6 +725,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 diff --git a/lua/fff/picker_ui/picker_ui_state.lua b/lua/fff/picker_ui/picker_ui_state.lua index dec4a47f..d200e202 100644 --- a/lua/fff/picker_ui/picker_ui_state.lua +++ b/lua/fff/picker_ui/picker_ui_state.lua @@ -65,6 +65,7 @@ M.state = { -- Selection state selected_files = {}, + selected_file_order = {}, selected_items = {}, -- Cross-mode suggestion state @@ -142,6 +143,7 @@ function M.reset_state() M.state.grep_regex_fallback_error = nil M.state.selected_files = {} + M.state.selected_file_order = {} M.state.selected_items = {} M.state.suggestion_items = nil M.state.suggestion_source = nil @@ -161,6 +163,7 @@ end -- Clear all selections function M.clear_selections() M.state.selected_files = {} + M.state.selected_file_order = {} M.state.selected_items = {} end @@ -210,12 +213,48 @@ function M.toggle_selection() was_selected = M.state.selected_files[item.relative_path] if was_selected then M.state.selected_files[item.relative_path] = nil + M.state.selected_file_order = vim.tbl_filter( + function(path) return path ~= item.relative_path end, + M.state.selected_file_order + ) else M.state.selected_files[item.relative_path] = true + table.insert(M.state.selected_file_order, item.relative_path) end end return was_selected end +-- Ordered, deduped selected file entries for opening (file mode only). +-- Each entry has the raw fff relative_path and a cwd-relative edit_path. +function M.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 + return M diff --git a/tests/picker_dir_resolution_spec.lua b/tests/picker_dir_resolution_spec.lua index 3d0db394..af3f0671 100644 --- a/tests/picker_dir_resolution_spec.lua +++ b/tests/picker_dir_resolution_spec.lua @@ -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() @@ -66,6 +66,11 @@ describe('picker find_files_in_dir path resolution (issue #389)', function() fd:write('-- issue #389 regression fixture\nreturn true\n') fd:close() + second_filename = 'issue389_second.lua' + fd = assert(io.open(target_dir .. '/' .. second_filename, 'w')) + fd:write('-- issue #389 second fixture\nreturn true\n') + fd:close() + -- Clear the DirChanged autocmd that a previous test run (e.g. fff_core_spec) -- may have installed. Without this, the :cd below triggers a scheduled -- change_indexing_directory(other_cwd) that races with our explicit @@ -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') @@ -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)