local M = {} local conf = require('fff.conf') local file_picker = require('fff.file_picker') local preview = require('fff.file_picker.preview') local utils = require('fff.utils') local location_utils = require('fff.location_utils') local layout = require('fff.layout') local picker_ui_state = require('fff.picker_ui.picker_ui_state') local picker_ui_utils = require('fff.picker_ui.utils') local ui_creator = require('fff.picker_ui.ui_creator') local search_manager = require('fff.picker_ui.search_manager') local renderer = require('fff.picker_ui.renderer') local preview_manager = require('fff.picker_ui.preview_manager') local navigation = require('fff.picker_ui.navigation') local layout_manager = require('fff.picker_ui.layout_manager') local canonicalize_fff_path = utils.canonicalize_fff_path local preview_config = conf.get().preview if preview_config then preview.setup(preview_config) end local function get_prompt_position() return layout.resolve_prompt_position(M.state.config) end -- Wire state from picker_ui_state module M.state = picker_ui_state.state -- Alias pure state functions from picker_ui_state M.clear_selections = picker_ui_state.clear_selections M.reset_history_state = picker_ui_state.reset_history_state -- Wire ui_creator module (UI creation, window/buffer/keymap setup) ui_creator.init(M) M.create_ui = ui_creator.create_ui M.setup_buffers = ui_creator.setup_buffers M.setup_windows = ui_creator.setup_windows M.setup_keymaps = ui_creator.setup_keymaps M.focus_list_win = ui_creator.focus_list_win M.focus_preview_win = ui_creator.focus_preview_win M.focus_input_win = ui_creator.focus_input_win M.open_preview = ui_creator.open_preview M.close_preview = ui_creator.close_preview -- Wire search_manager module (search, pagination, history) search_manager.init(M) M.update_results_sync = search_manager.update_results_sync M.update_results = search_manager.update_results M.load_page_at_index = search_manager.load_page_at_index M.load_next_page = search_manager.load_next_page M.load_previous_page = search_manager.load_previous_page M.on_input_change = search_manager.on_input_change M.cycle_grep_modes = search_manager.cycle_grep_modes M.recall_query_from_history = search_manager.recall_query_from_history M.cycle_forward_query = search_manager.cycle_forward_query M.get_suggestion_renderer = search_manager.get_suggestion_renderer -- Wire renderer module (list rendering, scroll, empty state) renderer.init(M) M.render_list = renderer.render_list M.render_after_cursor_move = renderer.render_after_cursor_move -- Wire preview_manager module (preview rendering, debounce, clear) preview_manager.init(M) M.close_preview_timer = preview_manager.close_preview_timer M.update_preview_debounced = preview_manager.update_preview_debounced M.update_preview_smart = preview_manager.update_preview_smart M.update_preview_title = preview_manager.update_preview_title M.update_preview = preview_manager.update_preview M.clear_preview = preview_manager.clear_preview -- Wire navigation module (cursor movement, preview scroll, pagination wraparound) navigation.init(M) M.wrap_to_first = navigation.wrap_to_first M.wrap_to_last = navigation.wrap_to_last M.move_up = navigation.move_up M.move_down = navigation.move_down M.scroll_preview_up = navigation.scroll_preview_up M.scroll_preview_down = navigation.scroll_preview_down M.grep_jump_to_next_file = navigation.grep_jump_to_next_file M.grep_jump_to_prev_file = navigation.grep_jump_to_prev_file -- Expose helpers used by navigation M.scroll_to_bottom = renderer.scroll_to_bottom -- Wire layout_manager module (relayout, close) layout_manager.init(M) M.relayout = layout_manager.relayout --- @class fff.ResumeState --- @field files table|nil Snapshot from last find_files session --- @field grep table|nil Snapshot from last live_grep session --- @field last_mode 'files'|'grep'|nil Mode of the most recently closed picker local resume_state = { files = nil, grep = nil, last_mode = nil } --- Save the current picker state for later resume, then close. function M.close() if M.state.query == '' then layout_manager.close() return end if not M.state.active then return end local snapshot = vim.deepcopy(M.state) snapshot.base_path = M.state.config and M.state.config.base_path or nil if M.state.mode == 'grep' then resume_state.grep = snapshot resume_state.last_mode = 'grep' else resume_state.files = snapshot resume_state.last_mode = 'files' end layout_manager.close() end --- Internal: restore picker from a saved state snapshot. ---@param state table The saved state table ---@param source_label string Label for error messages ---@return boolean local function restore_from_state(state, source_label) -- Ensure the file picker is initialized if not file_picker.is_initialized() then if not file_picker.setup() then vim.notify('Failed to initialize file picker', vim.log.levels.ERROR) return false end end -- Restore the picker with the saved config and mode M.state.renderer = state.renderer M.state.mode = state.mode 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 if state.base_path then require('fff.core').change_indexing_directory(state.base_path) end -- Use the saved config directly to restore the exact picker state M.state.config = state.config if not M.create_ui() then vim.notify('FFF: failed to create picker UI for ' .. source_label, vim.log.levels.ERROR) return false end M.state.active = true M.state.current_file_cache = state.current_file_cache -- Restore the full picker state M.state.query = state.query M.state.items = state.items or {} M.state.filtered_items = state.filtered_items or {} M.state.cursor = math.min(state.cursor or 1, #(state.filtered_items or {})) M.state.cursor = math.max(M.state.cursor, 1) M.state.location = state.location M.state.pagination = vim.deepcopy(state.pagination or { page_index = 0, page_size = 20, total_matched = 0, prefetch_margin = 5, grep_file_offsets = {}, grep_next_file_offset = 0, }) M.state.combo_visible = state.combo_visible ~= false M.state.combo_initial_cursor = state.combo_initial_cursor M.state.suggestion_items = state.suggestion_items M.state.suggestion_source = state.suggestion_source -- Writing the query below triggers on_lines -> on_input_change, which re-runs -- the search (results may have changed since close). Stash the saved cursor so -- that re-search restores the position instead of resetting it to the top. if state.query and state.query ~= '' then M.state.pending_restore_cursor = M.state.cursor vim.api.nvim_buf_set_lines(M.state.input_buf, 0, -1, false, { M.state.config.prompt .. state.query }) end -- Render the restored state M.render_list() M.update_preview() M.update_status() vim.api.nvim_set_current_win(M.state.input_win) -- Position cursor at end of query vim.schedule(function() if M.state.active and M.state.input_win and vim.api.nvim_win_is_valid(M.state.input_win) then local prompt_len = #M.state.config.prompt vim.api.nvim_win_set_cursor(M.state.input_win, { 1, prompt_len + #state.query }) vim.cmd('stopinsert') end end) return true end --- Close any active picker before resuming so the user can re-trigger --- resume to recreate the previous results without manually closing first. local function close_active_for_resume() if M.state.active then layout_manager.close() end end ---@return boolean|nil true if a picker was resumed, false otherwise function M.resume() close_active_for_resume() if resume_state.last_mode == 'grep' then return M.resume_live_grep() elseif resume_state.last_mode == 'files' then return M.resume_find_files() end if resume_state.grep then return restore_from_state(resume_state.grep, 'grep resume') end if resume_state.files then return restore_from_state(resume_state.files, 'files resume') end return M.open() end --- Resume the last file picker (find_files mode). --- Falls back to opening a new find_files picker if nothing to resume. ---@param opts? table Optional config overrides for fallback open ---@return boolean|nil function M.resume_find_files(opts) close_active_for_resume() if not resume_state.files then return M.open(opts) end return restore_from_state(resume_state.files, 'find_files resume') end --- Resume the last live_grep picker. --- Falls back to opening a new live_grep picker if nothing to resume. ---@param opts? table Optional config overrides for fallback open ---@return boolean function M.resume_live_grep(opts) close_active_for_resume() if not resume_state.grep then local config = conf.get() local grep_renderer = require('fff.picker_ui.grep_renderer') local grep_config = vim.tbl_deep_extend('force', config.grep or {}, (opts and opts.grep) or {}) M.open(vim.tbl_deep_extend('force', { mode = 'grep', renderer = grep_renderer, grep_config = grep_config, title = 'Live Grep', }, opts or {})) return true end return restore_from_state(resume_state.grep, 'live_grep resume') end function M.toggle_debug() local config_changed = conf.toggle_debug() if config_changed then local current_query = M.state.query local current_items = M.state.items local current_cursor = M.state.cursor -- Preserve mode-specific state across close/open cycle local current_mode = M.state.mode local current_renderer = M.state.renderer local current_grep_mode = M.state.grep_mode 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() M.open({ mode = current_mode, renderer = current_renderer, grep_config = current_grep_config, }) M.state.query = current_query M.state.items = current_items M.state.cursor = current_cursor 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() M.update_status() vim.schedule(function() if M.state.active and M.state.input_win then vim.api.nvim_set_current_win(M.state.input_win) vim.cmd('startinsert!') end end) else M.update_results() end end function M.render_debounced() vim.schedule(function() if M.state.active then M.render_list() M.update_preview() M.update_status() end end) end --- Update status information on the right side of input using virtual text function M.update_status(progress) if not M.state.active or not M.state.ns_id then return end local config = M.state.config if config == nil then return end if M.state.mode == 'grep' then -- Determine available modes to decide if we should show the mode indicator -- Use grep_config.modes if provided, otherwise fall back to global config ---@diagnostic disable-next-line: undefined-field local modes = (M.state.grep_config and M.state.grep_config.modes) or config.grep.modes or { 'plain', 'regex', 'fuzzy' } -- When regex compilation failed and we fell back to literal search, show a warning local fallback_label = nil if M.state.grep_regex_fallback_error then fallback_label = 'invalid regex, using literal' end -- If only one mode configured and no fallback error, hide the mode indicator completely if #modes <= 1 and not fallback_label then -- Clear any existing status and don't show anything vim.api.nvim_buf_clear_namespace(M.state.input_buf, M.state.ns_id, 0, -1) M.state.last_status_info = nil return end local keybind = config.keymaps.cycle_grep_modes -- Normalize: if it's a table of keys, use the first one for display if type(keybind) == 'table' then keybind = keybind[1] or '' end local mode_labels = { plain = 'plain', regex = 'regex', fuzzy = 'fuzzy', } local mode_label = mode_labels[M.state.grep_mode] or 'plain' local hl if M.state.grep_mode == 'plain' then hl = config.hl.grep_plain_active or 'Comment' elseif M.state.grep_mode == 'regex' then hl = config.hl.grep_regex_active or 'DiagnosticInfo' else -- fuzzy hl = config.hl.grep_fuzzy_active or 'DiagnosticHint' end local cache_key = keybind .. M.state.grep_mode .. (fallback_label or '') if cache_key == M.state.last_status_info then return end M.state.last_status_info = cache_key vim.api.nvim_buf_clear_namespace(M.state.input_buf, M.state.ns_id, 0, -1) local win_width = vim.api.nvim_win_get_width(M.state.input_win) local available_width = win_width - 2 if fallback_label then local total_len = #fallback_label local col_position = available_width - total_len vim.api.nvim_buf_set_extmark(M.state.input_buf, M.state.ns_id, 0, 0, { virt_text = { { fallback_label, 'DiagnosticWarn' } }, virt_text_win_col = col_position, }) else local total_len = #keybind + 1 + #mode_label local col_position = available_width - total_len vim.api.nvim_buf_set_extmark(M.state.input_buf, M.state.ns_id, 0, 0, { virt_text = { { keybind .. ' ', hl }, { mode_label, hl }, }, virt_text_win_col = col_position, }) end return end -- File picker mode: show match counts local status_info if progress and progress.is_scanning then status_info = string.format('Indexing files %d', progress.scanned_files_count) else local search_metadata = file_picker.get_search_metadata() if #M.state.query < 2 then status_info = string.format('%d', search_metadata.total_files) else status_info = string.format('%d/%d', search_metadata.total_matched, search_metadata.total_files) end end if status_info == M.state.last_status_info then return end M.state.last_status_info = status_info vim.api.nvim_buf_clear_namespace(M.state.input_buf, M.state.ns_id, 0, -1) local win_width = vim.api.nvim_win_get_width(M.state.input_win) local available_width = win_width - 2 local col_position = available_width - #status_info vim.api.nvim_buf_set_extmark(M.state.input_buf, M.state.ns_id, 0, 0, { virt_text = { { status_info, 'LineNr' } }, virt_text_win_col = col_position, }) end --- Check whether the given window has 'winfixbuf' enabled. --- pcall-guarded so this stays safe on Neovim versions that predate the option. local window_has_winfixbuf = utils.window_has_winfixbuf --- Toggle selection for the current item. --- In grep mode, selection is per-occurrence; in file mode, per-file. function M.toggle_select() if not M.state.active then return end local was_selected = picker_ui_state.toggle_selection() M.render_list() -- only when selecting the element not deselecting if not was_selected then if get_prompt_position() == 'bottom' then M.move_up() else M.move_down() end end end M.send_to_quickfix = picker_ui_utils.send_to_quickfix function M.select(action) if not M.state.active then return end local items = M.state.filtered_items if #items == 0 or M.state.cursor > #items then return end ---@diagnostic disable-next-line: need-check-nil local item = items[M.state.cursor] if not item then return end action = action or 'edit' -- Anchor against the indexer's base_path (may differ from cwd), then rephrase -- as cwd-relative for a nicer buffer name when possible. When outside cwd, -- fnamemodify(':.') leaves the absolute path intact. local abs_path = canonicalize_fff_path(item.relative_path) if not abs_path then return end local relative_path = vim.fn.fnamemodify(abs_path, ':.') local location = M.state.location -- Capture location before closing 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 config = M.state.config -- Capture config before M.close() resets state -- 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 location.col = item.col + 1 -- Convert 0-based byte col to 1-based end end -- Fallback: if location is nil but query has a :line suffix, parse it directly if not location and query and query ~= '' then local line_str = query:match(':(%d+)$') if line_str then local line_num = tonumber(line_str) if line_num and line_num > 0 then local col_and_line = query:match(':(%d+):(%d+)$') if col_and_line then local l, c = query:match(':(%d+):(%d+)$') location = { line = tonumber(l), col = tonumber(c) } else location = { line = line_num } end end 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() local on_submit = config and config.on_submit -- 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 type(on_submit) == 'function' then local ok, err = pcall(on_submit, item, { action = action, path = abs_path, relative_path = relative_path, location = location, query = query, mode = mode, }) if not ok then vim.notify('FFF: on_submit error: ' .. tostring(err), vim.log.levels.ERROR) end else if config and config.select and type(config.select.select_window) == 'function' then local ok, win = pcall(config.select.select_window, vim.api.nvim_get_current_buf(), action) if not ok then vim.notify('FFF: select.select_window error: ' .. tostring(win), vim.log.levels.WARN) elseif type(win) == 'number' and vim.api.nvim_win_is_valid(win) then vim.api.nvim_set_current_win(win) end 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(edit_path)) opened_via_split = true 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 vim.cmd('vsplit ' .. vim.fn.fnameescape(relative_path)) elseif action == 'tab' then vim.cmd('tabedit ' .. vim.fn.fnameescape(relative_path)) end if location then location_utils.jump_to_location(location) end end if query and query ~= '' then local cfg = config or conf.get() if cfg.history and cfg.history.enabled then local fff = require('fff.core').ensure_initialized() -- 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 end end end) end --- @return string|nil Current file cache path local function get_current_file_cache(base_path) if not base_path then return nil end local current_buf = vim.api.nvim_get_current_buf() if not current_buf or not vim.api.nvim_buf_is_valid(current_buf) then return nil end local current_file = vim.api.nvim_buf_get_name(current_buf) if current_file == '' then return nil end -- Use vim.uv.fs_stat to check if file exists and is readable local stat = vim.uv.fs_stat(current_file) if not stat or stat.type ~= 'file' then return nil end local absolute_path = vim.fn.fnamemodify(current_file, ':p') local resolved_abs = vim.fn.resolve(absolute_path) local resolved_base = vim.fn.resolve(base_path) -- icloud direcrtoes on macos contain a lot of special characters that break -- the fnamemodify which have to escaped with % local escaped_base = resolved_base:gsub('([%%^$()%.%[%]*+%-?])', '%%%1') local relative_path = resolved_abs:gsub('^' .. escaped_base .. '/', '') if relative_path == '' or relative_path == resolved_abs then return nil end return relative_path end --- Helper function for common picker initialization --- @param opts table|nil Options passed to the picker --- @return table|nil, string|nil Merged configuration and base path, nil config if initialization failed local function initialize_picker(opts) local base_path = opts and opts.cwd or vim.uv.cwd() -- Initialize file picker if needed if not file_picker.is_initialized() then if not file_picker.setup() then vim.notify('Failed to initialize file picker', vim.log.levels.ERROR) return nil end end local config = conf.get() local merged_config = vim.tbl_deep_extend('force', config or {}, opts or {}) return merged_config, base_path end --- Helper function to open UI with optional prefetched results --- @param query string|nil Pre-filled query (nil for empty) --- @param results table|nil Pre-fetched results (nil to search normally) --- @param location table|nil Pre-fetched location data --- @param merged_config table Merged configuration --- @param current_file_cache string|nil Current file cache local function open_ui_with_state(query, results, location, merged_config, current_file_cache) M.state.config = merged_config if not M.create_ui() then vim.notify('Failed to create picker UI', vim.log.levels.ERROR) return false end M.state.active = true M.state.current_file_cache = current_file_cache -- Set up initial state if query then M.state.query = query vim.api.nvim_buf_set_lines(M.state.input_buf, 0, -1, false, { M.state.config.prompt .. query }) else M.state.query = '' end if results then -- Use prefetched results M.state.items = results M.state.filtered_items = results M.state.cursor = #results > 0 and 1 or 1 M.state.location = location M.render_list() M.update_preview() M.update_status() else M.update_results() M.clear_preview() M.update_status() end vim.api.nvim_set_current_win(M.state.input_win) -- Position cursor at end of query if there is one if query then vim.schedule(function() if M.state.active and M.state.input_win and vim.api.nvim_win_is_valid(M.state.input_win) then vim.api.nvim_win_set_cursor(M.state.input_win, { 1, #M.state.config.prompt + #query }) vim.cmd('startinsert!') end end) else vim.cmd('startinsert!') end M.monitor_scan_progress(0) return true end --- Execute a search query with callback handling before potentially opening the UI --- @param query string The search query to execute --- @param callback function Function called with results: function(results, metadata, location, get_file_score) -> boolean --- @param opts? table Optional configuration to override defaults (same as M.open) --- @return boolean true if callback handled results, false if UI was opened function M.open_with_callback(query, callback, opts) if M.state.active then return false end -- open_with_callback runs the file-picker flow, never grep. Reset the -- renderer/mode/grep_config defensively so we can't inherit stale state -- from a previous live_grep session (close() must always do this too, -- but belt-and-braces). M.state.renderer = nil M.state.mode = nil M.state.grep_config = nil local merged_config, base_path = initialize_picker(opts) if not merged_config then return false end local current_file_cache = get_current_file_cache(base_path) local results = file_picker.search_files(query, current_file_cache, nil, nil, nil) local metadata = file_picker.get_search_metadata() local location = file_picker.get_search_location() local callback_handled = false if type(callback) == 'function' then local ok, result = pcall(callback, results, metadata, location, file_picker.get_file_score) if ok then callback_handled = result == true else vim.notify('Error in search callback: ' .. tostring(result), vim.log.levels.ERROR) end end if callback_handled then return true end open_ui_with_state(query, results, location, merged_config, current_file_cache) return false 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 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 M.state.grep_config = opts and opts.grep_config or nil local merged_config, base_path = initialize_picker(opts) if not merged_config then return end if base_path then require('fff.core').change_indexing_directory(base_path) end -- Initialize grep_mode to first configured mode when opening in grep mode if M.state.mode == 'grep' then -- Use grep_config.modes if provided, otherwise fall back to global config ---@diagnostic disable-next-line: undefined-field local modes = (M.state.grep_config and M.state.grep_config.modes) or merged_config.grep.modes or { 'plain', 'regex', 'fuzzy' } M.state.grep_mode = modes[1] or 'plain' end local current_file_cache = get_current_file_cache(base_path) local query = opts and opts.query or nil ---@type string|nil return open_ui_with_state(query, nil, nil, merged_config, current_file_cache) end function M.monitor_scan_progress(iteration) if not M.state.active then return end local progress = file_picker.get_scan_progress() if progress.is_scanning then M.update_status(progress) -- progressive decay for larger directories local timeout if iteration < 10 then timeout = 100 elseif iteration < 20 then timeout = 300 else timeout = 500 end vim.defer_fn(function() M.monitor_scan_progress(iteration + 1) end, timeout) else M.update_results() end end return M