From 2551806d26e55448d6459ac53d7dfcdaf96f936a Mon Sep 17 00:00:00 2001 From: gshahbazian Date: Sat, 30 May 2026 10:34:54 -0700 Subject: [PATCH] open all multi-selected files on cr --- lua/fff/picker_ui.lua | 105 +++++++++++++++++++++------ tests/picker_dir_resolution_spec.lua | 53 +++++++++++++- 2 files changed, 131 insertions(+), 27 deletions(-) diff --git a/lua/fff/picker_ui.lua b/lua/fff/picker_ui.lua index a54843db..efd8d56d 100644 --- a/lua/fff/picker_ui.lua +++ b/lua/fff/picker_ui.lua @@ -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) @@ -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() @@ -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() @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/tests/picker_dir_resolution_spec.lua b/tests/picker_dir_resolution_spec.lua index 57d4279d..c7237b6c 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() @@ -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) @@ -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)