diff --git a/Makefile b/Makefile index 3a68f558..18a147c2 100644 --- a/Makefile +++ b/Makefile @@ -68,12 +68,14 @@ test-rust: test-lua: test-setup build @output=$$(nvim --headless -u tests/minimal_init.lua \ -c "PlenaryBustedDirectory tests/ {minimal_init = 'tests/minimal_init.lua'}" 2>&1); \ + rc=$$?; \ echo "$$output"; \ if echo "$$output" | grep -qE "SIG(SEGV|ABRT|BUS|FPE|ILL)"; then \ echo ""; \ echo "FAIL: native crash detected during lua tests"; \ exit 1; \ - fi + fi; \ + exit $$rc # mini.test reference_screenshot snapshots. Separate runner because mini.test # spawns child processes and uses its own collector (incompatible with @@ -81,12 +83,14 @@ test-lua: test-setup build test-lua-snap: test-setup build @output=$$(nvim --headless -u tests/minimal_init.lua \ -c "lua require('mini.test').run_file('tests/picker_ui_snap.lua')" 2>&1); \ + rc=$$?; \ echo "$$output"; \ if echo "$$output" | grep -qE "SIG(SEGV|ABRT|BUS|FPE|ILL)"; then \ echo ""; \ echo "FAIL: native crash detected during snapshot tests"; \ exit 1; \ - fi + fi; \ + exit $$rc test-version: test-setup nvim --headless -u tests/minimal_init.lua \ diff --git a/README.md b/README.md index 11b2c3fd..44c9044d 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,31 @@ require('fff').live_grep({ grep = { modes = { 'fuzzy', 'plain' } } }) require('fff').live_grep({ query = 'search term' }) -- pre-fill ``` +### Custom select handler (`on_submit`) + +Both `find_files` and `live_grep` accept an `on_submit` callback. When provided, the picker calls your callback with the selected item and skips its default `:edit`/split machinery — useful for embedding the picker into other plugins (e.g. inserting a link, sending a path to an external tool). + +```lua +require('fff').find_files({ + on_submit = function(item, ctx) + -- item: raw picker item (relative_path, name, ...) + -- ctx.action: 'edit' | 'split' | 'vsplit' | 'tab' (which keymap was used) + -- ctx.path: absolute path resolved against the indexer's base_path + -- ctx.relative_path: cwd-relative path for nicer display + -- ctx.location: { line, col } for grep matches, otherwise nil + -- ctx.mode: 'grep' | nil + -- ctx.query: the active search query + vim.notify('picked ' .. ctx.path) + end, +}) + +require('fff').live_grep({ + on_submit = function(item, ctx) + vim.fn.setreg('+', ctx.path .. ':' .. (ctx.location and ctx.location.line or 1)) + end, +}) +``` + ### Constraints Both find and grep accept these tokens to refine a query: diff --git a/lua/fff/main.lua b/lua/fff/main.lua index 92d9a746..a80bb522 100644 --- a/lua/fff/main.lua +++ b/lua/fff/main.lua @@ -7,7 +7,7 @@ M.state = { initialized = false } function M.setup(config) vim.g.fff = config end --- Find files in current directory ---- @param opts? table Optional configuration {renderer = custom_renderer} +--- @param opts? {cwd?: string, title?: string, prompt?: string, layout?: table, query?: string, renderer?: table, on_submit?: fun(item: table, ctx: {action: string, path: string, relative_path: string, location: table|nil, mode: string|nil, query: string})} Optional configuration. Set `on_submit` to handle the selected file yourself; the picker will skip its default `:edit` flow and call your callback instead. function M.find_files(opts) local picker_ok, picker_ui = pcall(require, 'fff.picker_ui') if picker_ok then @@ -18,7 +18,7 @@ function M.find_files(opts) end --- Live grep: search file contents in the current directory ---- @param opts? {cwd?: string, title?: string, prompt?: string, layout?: table, grep?: {max_file_size?: number, smart_case?: boolean, max_matches_per_file?: number, modes?: string[]}, query?: string} Optional configuration overrides +--- @param opts? {cwd?: string, title?: string, prompt?: string, layout?: table, grep?: {max_file_size?: number, smart_case?: boolean, max_matches_per_file?: number, modes?: string[]}, query?: string, on_submit?: fun(item: table, ctx: {action: string, path: string, relative_path: string, location: table|nil, mode: string|nil, query: string})} Optional configuration. Set `on_submit` to handle the selected match yourself; the picker will skip its default `:edit` flow and call your callback instead. function M.live_grep(opts) local picker_ok, picker_ui = pcall(require, 'fff.picker_ui') if not picker_ok then diff --git a/lua/fff/picker_ui.lua b/lua/fff/picker_ui.lua index dddf250c..d2aa4e2f 100644 --- a/lua/fff/picker_ui.lua +++ b/lua/fff/picker_ui.lua @@ -857,6 +857,11 @@ M.state = { -- suggestion_source: 'grep' (suggestions from grep) or 'files' (suggestions from file search) suggestion_items = nil, suggestion_source = nil, + + -- When set, M.select skips the default :edit/:split machinery and invokes + -- this callback with the selected item. Used by find_files/live_grep to + -- let callers handle the selected file directly. + on_submit = nil, } function M.create_ui() @@ -2811,6 +2816,7 @@ function M.select(action) local query = M.state.query -- Capture query before closing for tracking local mode = M.state.mode -- Capture mode before closing for tracking local suggestion_source = M.state.suggestion_source -- Capture suggestion context + local on_submit = M.state.on_submit -- Capture callback before closing -- 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' @@ -2841,7 +2847,17 @@ function M.select(action) vim.cmd('stopinsert') M.close() - if action == 'edit' then + if type(on_submit) == 'function' then + local ok, err = pcall(on_submit, item, { + action = action, + path = abs_path, + relative_path = relative_path, + location = location, + mode = mode, + query = query, + }) + if not ok then vim.notify('FFF on_submit callback error: ' .. tostring(err), vim.log.levels.ERROR) end + elseif 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 }) @@ -2874,6 +2890,7 @@ function M.select(action) -- Derive side effects on vim schedule to ensure they run after the file is opened vim.schedule(function() + if on_submit then return end -- callback owns post-select side effects if location then location_utils.jump_to_location(location) end if query and query ~= '' then @@ -2999,6 +3016,7 @@ function M.close() M.state.grep_regex_fallback_error = nil M.state.suggestion_items = nil M.state.suggestion_source = nil + M.state.on_submit = nil M.state.restore_paste = false M.state.combo_visible = true M.state.combo_initial_cursor = nil @@ -3147,15 +3165,21 @@ function M.open_with_callback(query, callback, opts) end --- Open the file picker UI ---- @param opts? {cwd?: string, title?: string, prompt?: string, max_results?: number, max_threads?: number, layout?: {width?: number|function, height?: number|function, prompt_position?: string|function, preview_position?: string|function, preview_size?: number|function}, renderer?: table, mode?: string, grep_config?: table, query?: string} Optional configuration to override defaults +--- @param opts? {cwd?: string, title?: string, prompt?: string, max_results?: number, max_threads?: number, layout?: {width?: number|function, height?: number|function, prompt_position?: string|function, preview_position?: string|function, preview_size?: number|function}, renderer?: table, mode?: string, grep_config?: table, query?: string, on_submit?: fun(item: table, ctx: {action: string, path: string, relative_path: string, location: table|nil, mode: string|nil, query: string})} Optional configuration to override defaults function M.open(opts) if M.state.active then return end + if opts and opts.on_submit ~= nil and type(opts.on_submit) ~= 'function' then + vim.notify('FFF: opts.on_submit must be a function', vim.log.levels.ERROR) + return + end + M.state.selected_files = {} M.state.selected_items = {} M.state.renderer = opts and opts.renderer or nil M.state.mode = opts and opts.mode or nil M.state.grep_config = opts and opts.grep_config or nil + M.state.on_submit = opts and opts.on_submit or nil local merged_config, base_path = initialize_picker(opts) if not merged_config then return end diff --git a/tests/on_submit_callback_spec.lua b/tests/on_submit_callback_spec.lua new file mode 100644 index 00000000..1889566a --- /dev/null +++ b/tests/on_submit_callback_spec.lua @@ -0,0 +1,123 @@ +---@diagnostic disable: undefined-field, missing-fields +local fff = require('fff') +local fff_rust = require('fff.rust') +local picker_ui = require('fff.picker_ui') +local test_utils = require('tests.utils') + +--- Wait until the picker is open with at least one item, then move cursor +--- to the item whose `name` matches `target_name`. +local function wait_for_item(target_name, timeout_ms) + local found = vim.wait(timeout_ms or 10000, function() + if not picker_ui.state.active then return false end + local items = picker_ui.state.filtered_items + if not items or #items == 0 then return false end + for _, item in ipairs(items) do + if item.name == target_name then return true end + end + return false + end, 50) + if not found then return false end + for i, item in ipairs(picker_ui.state.filtered_items) do + if item.name == target_name then + picker_ui.state.cursor = i + return true + end + end + return false +end + +--- Trigger the picker's `select` keymap (``) the same way a user would. +local function press_select() + local keys = vim.api.nvim_replace_termcodes('', true, false, true) + vim.api.nvim_feedkeys(keys, 'x', false) +end + +describe('picker on_submit callback (issue #247)', function() + local sandbox_root, target_dir + local main_filename = 'fff_target_main.lua' + local readme_filename = 'README_FIXTURE.md' + local needle = 'fff_target_unique_needle' + + before_each(function() + sandbox_root = vim.fn.tempname() + target_dir = sandbox_root .. '/on-submit-target' + vim.fn.mkdir(target_dir, 'p') + + local fd = assert(io.open(target_dir .. '/' .. main_filename, 'w')) + fd:write('-- ' .. needle .. '\nreturn 1\n') + fd:close() + + fd = assert(io.open(target_dir .. '/' .. readme_filename, 'w')) + fd:write('docs only — no needle here\n') + fd:close() + + pcall(vim.api.nvim_del_augroup_by_name, 'fff_file_tracking') + vim.g.fff = {} + end) + + after_each(function() + pcall(picker_ui.close) + pcall(fff_rust.stop_background_monitor) + pcall(fff_rust.cleanup_file_picker) + if sandbox_root then vim.fn.delete(sandbox_root, 'rf') end + vim.g.fff = nil + end) + + it('find_files invokes on_submit with the selected item instead of editing', function() + local pre_buf = vim.api.nvim_buf_get_name(0) + + local captured = {} + fff.find_files({ + cwd = target_dir, + on_submit = function(item, ctx) + captured.item = item + captured.ctx = ctx + end, + }) + + assert.is_true(wait_for_item(main_filename, 10000), 'picker never surfaced fixture file') + press_select() + + local fired = vim.wait(2000, function() return captured.item ~= nil end, 20) + assert.is_true(fired, 'on_submit was not invoked') + + assert.is_table(captured.ctx) + assert.are.equal('edit', captured.ctx.action) + assert.are.equal(main_filename, captured.item.name) + assert.are.equal(test_utils.normalize(target_dir .. '/' .. main_filename), test_utils.normalize(captured.ctx.path)) + assert.is_nil(captured.ctx.mode, 'find_files mode should be nil') + + -- The callback owns the selection: picker must not have :edit'd a buffer. + assert.are.equal(pre_buf, vim.api.nvim_buf_get_name(0), ':edit ran despite on_submit being set') + assert.is_false(picker_ui.state.active) + assert.is_nil(picker_ui.state.on_submit) + end) + + it('live_grep invokes on_submit with grep match item and location', function() + local pre_buf = vim.api.nvim_buf_get_name(0) + + local captured = {} + fff.live_grep({ + cwd = target_dir, + query = needle, + on_submit = function(item, ctx) + captured.item = item + captured.ctx = ctx + end, + }) + + assert.is_true(wait_for_item(main_filename, 10000), 'live_grep never surfaced match') + press_select() + + local fired = vim.wait(2000, function() return captured.item ~= nil end, 20) + assert.is_true(fired, 'on_submit was not invoked for live_grep') + + assert.are.equal(main_filename, captured.item.name) + assert.is_table(captured.ctx.location, 'grep callback should receive a location') + assert.are.equal(1, captured.ctx.location.line) + + assert.are.equal(pre_buf, vim.api.nvim_buf_get_name(0), ':edit ran despite on_submit being set') + assert.is_false(picker_ui.state.active) + assert.is_nil(picker_ui.state.on_submit) + end) +end) diff --git a/tests/picker_dir_resolution_spec.lua b/tests/picker_dir_resolution_spec.lua index e949bf56..32e75dee 100644 --- a/tests/picker_dir_resolution_spec.lua +++ b/tests/picker_dir_resolution_spec.lua @@ -8,25 +8,7 @@ local fff_rust = require('fff.rust') local picker_ui = require('fff.picker_ui') local file_picker = require('fff.file_picker') - ---- Normalise a path so that comparisons work on every OS. ---- Windows complicates things: Rust may return forward-slash paths while ---- vim.fn.resolve uses backslashes, temp paths may contain 8.3 short names ---- (RUNNER~1), and the filesystem is case-insensitive. ---- vim.uv.fs_realpath expands 8.3 names on Windows (unlike vim.fn.resolve). ---- @param p string ---- @return string -local function norm(p) - -- fs_realpath is the closest Lua equivalent of Rust's std::fs::canonicalize - -- and expands 8.3 short names on Windows. - local rp = vim.uv.fs_realpath(p) or vim.fn.fnamemodify(vim.fn.resolve(p), ':p') - local n = vim.fs.normalize(rp) - -- Strip trailing slash for consistent comparison - n = n:gsub('/$', '') - -- Case-fold on Windows (drive letters, 8.3 short names, etc.) - if vim.fn.has('win32') == 1 then n = n:lower() end - return n -end +local norm = require('tests.utils').normalize --- `change_indexing_directory` swaps the picker on a background thread, so the --- `FILE_PICKER` global may still point at the *old* picker for a moment — diff --git a/tests/utils.lua b/tests/utils.lua new file mode 100644 index 00000000..f147afe4 --- /dev/null +++ b/tests/utils.lua @@ -0,0 +1,14 @@ +local M = {} + +-- Normalize paths for windows +--- @param p string +--- @return string +function M.normalize(p) + local rp = vim.uv.fs_realpath(p) or vim.fn.fnamemodify(vim.fn.resolve(p), ':p') + local n = vim.fs.normalize(rp) + n = n:gsub('/$', '') + if vim.fn.has('win32') == 1 then n = n:lower() end + return n +end + +return M