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
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -68,25 +68,29 @@ 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
# PlenaryBustedDirectory).
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 \
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions lua/fff/main.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
28 changes: 26 additions & 2 deletions lua/fff/picker_ui.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions tests/on_submit_callback_spec.lua
Original file line number Diff line number Diff line change
@@ -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 (`<CR>`) the same way a user would.
local function press_select()
local keys = vim.api.nvim_replace_termcodes('<CR>', 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)
20 changes: 1 addition & 19 deletions tests/picker_dir_resolution_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
14 changes: 14 additions & 0 deletions tests/utils.lua
Original file line number Diff line number Diff line change
@@ -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
Loading