chore: import upstream snapshot with attribution
Python CI / Python bindings (ubuntu-latest) (push) Failing after 0s
Build & Publish / Build Neovim aarch64-linux-android (push) Failing after 0s
Build & Publish / Build Neovim aarch64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build MCP x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build Python wheels aarch64 (ubuntu-latest) (push) Failing after 0s
Build & Publish / Build Python wheels x86_64 (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Python sdist (push) Failing after 1s
Rust CI / Fuzz Tests (ubuntu-latest) (push) Failing after 1s
Rust CI / Build i686-unknown-linux-gnu (push) Failing after 0s
Rust CI / cargo clippy (push) Failing after 1s
e2e Tests / e2e (ubuntu-latest) (push) Failing after 1s
Lua CI / luacheck lint (push) Failing after 1s
Build & Publish / Build Neovim aarch64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build Neovim x86_64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build Neovim x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build C FFI aarch64-linux-android (push) Failing after 0s
Build & Publish / Build C FFI aarch64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build C FFI aarch64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build C FFI x86_64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build C FFI x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build MCP aarch64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build MCP aarch64-unknown-linux-musl (push) Failing after 0s
Build & Publish / Build MCP x86_64-unknown-linux-gnu (push) Failing after 1s
Spelling / Spell Check with Typos (push) Failing after 0s
Rust CI / Test (ubuntu-latest) (push) Failing after 0s
Rust CI / cargo fmt (push) Failing after 0s
Stylua / Check lua files using Stylua (push) Failing after 1s
Lua CI / lua-language-server type check (push) Failing after 12m29s
e2e Tests / e2e (alpine-musl) (push) Successful in 57m31s
e2e Tests / e2e (macos-latest) (push) Has been cancelled
Build & Publish / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Rust CI / Test (windows-latest) (push) Has been cancelled
e2e Tests / e2e (windows-latest) (push) Has been cancelled
Nix CI / check (push) Has been cancelled
Python CI / Python bindings (macos-latest) (push) Has been cancelled
Python CI / Python bindings (windows-latest) (push) Has been cancelled
Build & Publish / Build Neovim aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Neovim x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build C FFI aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build C FFI x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build C FFI x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Python wheels aarch64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (windows-latest) (push) Has been cancelled
Build & Publish / Release (push) Has been cancelled
Build & Publish / Publish Python wheels to PyPI (push) Has been cancelled
Build & Publish / Publish Rust crates (push) Has been cancelled
Build & Publish / Publish npm packages (push) Has been cancelled
Rust CI / Fuzz Tests (windows-latest) (push) Has been cancelled
Rust CI / Test (macos-latest) (push) Has been cancelled
Rust CI / Fuzz Tests (macos-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:27:16 +08:00
commit d5f207424b
328 changed files with 87463 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
return require('fff.main')
+493
View File
@@ -0,0 +1,493 @@
local M = {}
--- @class FffLayoutConfig
--- @field height number
--- @field width number
--- @field prompt_position string
--- @field preview_position string
--- @field preview_size number
--- @field min_list_height number
--- @field show_scrollbar boolean
--- @field path_shorten_strategy string
--- @field border? 'single'|'double'|'rounded'|'solid'|'shadow'|'none' Border preset; falls back to `vim.o.winborder` when nil
--- @class FffPreviewConfig
--- @field enabled boolean
--- @field max_size number
--- @field chunk_size number
--- @field binary_file_threshold number
--- @field imagemagick_info_format_str string
--- @field line_numbers boolean
--- @field cursorlineopt string
--- @field wrap_lines boolean
--- @field filetypes table<string, table>
--- @class FffKeymapsConfig
--- @field close string
--- @field select string
--- @field select_split string
--- @field select_vsplit string
--- @field select_tab string
--- @field move_up string|string[]
--- @field move_down string|string[]
--- @field preview_scroll_up string
--- @field preview_scroll_down string
--- @field toggle_debug string
--- @field cycle_grep_modes string
--- @field cycle_previous_query string
--- @field cycle_forward_query string
--- @field grep_jump_to_next_file string|string[]
--- @field grep_jump_to_prev_file string|string[]
--- @field toggle_select string
--- @field send_to_quickfix string
--- @field focus_list string
--- @field focus_preview string
--- @class FffFrecencyConfig
--- @field enabled boolean
--- @field db_path string
--- @class FffHistoryConfig
--- @field enabled boolean
--- @field db_path string
--- @field min_combo_count number
--- @field combo_boost_score_multiplier number
--- @class FffGrepConfig
--- @field max_file_size number
--- @field max_matches_per_file number
--- @field smart_case boolean
--- @field time_budget_ms number
--- @field modes string[]
--- @field trim_whitespace boolean
--- @field location_format string
--- @alias FffSelectAction 'edit' | 'split' | 'vsplit' | 'tab'
--- @class FffSelectConfig
--- @field select_window fun(current_buf: integer, action: FffSelectAction): integer|nil
--- @class FffConfig
--- @field base_path string
--- @field prompt string
--- @field title string
--- @field max_results number
--- @field max_threads number
--- @field lazy_sync boolean
--- @field prompt_vim_mode boolean
--- @field follow_symlinks boolean
--- @field enable_fs_root_scanning boolean
--- @field enable_home_dir_scanning boolean
--- @field layout FffLayoutConfig
--- @field preview FffPreviewConfig
--- @field keymaps FffKeymapsConfig
--- @field hl table<string, string>
--- @field frecency FffFrecencyConfig
--- @field history FffHistoryConfig
--- @field select FffSelectConfig
--- @field git table
--- @field debug table
--- @field logging table
--- @field wrap_around boolean
--- @field file_picker table
--- @field grep FffGrepConfig
---@class fff.conf.State
local state = {
---@type FffConfig|nil
config = nil,
}
local DEPRECATION_RULES = {
{
-- Top-level width -> layout.width
old_path = { 'width' },
new_path = { 'layout', 'width' },
message = 'config.width is deprecated. Use config.layout.width instead.',
},
{
-- Top-level height -> layout.height
old_path = { 'height' },
new_path = { 'layout', 'height' },
message = 'config.height is deprecated. Use config.layout.height instead.',
},
{
-- preview.width -> layout.preview_size
old_path = { 'preview', 'width' },
new_path = { 'layout', 'preview_size' },
message = 'config.preview.width is deprecated. Use config.layout.preview_size instead.',
},
{
-- layout.preview_width -> layout.preview_size
old_path = { 'layout', 'preview_width' },
new_path = { 'layout', 'preview_size' },
message = 'config.layout.preview_width is deprecated. Use config.layout.preview_size instead.',
},
}
--- Get value from nested table using path array
--- @param tbl table Source table
--- @param path table Array of keys to traverse
--- @return any|nil Value at path or nil if not found
local function get_nested_value(tbl, path)
local current = tbl
for _, key in ipairs(path) do
if type(current) ~= 'table' or current[key] == nil then return nil end
current = current[key]
end
return current
end
--- Set value in nested table using path array, creating intermediate tables
--- @param tbl table Target table
--- @param path table Array of keys to traverse
--- @param value any Value to set
local function set_nested_value(tbl, path, value)
local current = tbl
for i = 1, #path - 1 do
local key = path[i]
if type(current[key]) ~= 'table' then current[key] = {} end
current = current[key]
end
current[path[#path]] = value
end
--- Remove value from nested table using path array
--- @param tbl table Target table
--- @param path table Array of keys to traverse
local function remove_nested_value(tbl, path)
if #path == 0 then return end
local current = tbl
for i = 1, #path - 1 do
local key = path[i]
if type(current[key]) ~= 'table' then return end
current = current[key]
end
current[path[#path]] = nil
end
--- Handle deprecated configuration options with migration warnings
--- @param user_config table User provided configuration
--- @return table Migrated configuration
local function handle_deprecated_config(user_config)
if not user_config then return {} end
local migrated_config = vim.deepcopy(user_config)
for _, rule in ipairs(DEPRECATION_RULES) do
local old_value = get_nested_value(user_config, rule.old_path)
if old_value ~= nil then
set_nested_value(migrated_config, rule.new_path, old_value)
remove_nested_value(migrated_config, rule.old_path)
vim.notify('FFF: ' .. rule.message, vim.log.levels.WARN)
end
end
return migrated_config
end
---@param name table list of highlight groups to choose from
---@return string one of the provided groups
local function fallback_hl(name)
local resolved_hl
for _, hl in ipairs(name) do
local resolved_group = vim.api.nvim_get_hl(0, { name = hl })
if not vim.tbl_isempty(resolved_group) then resolved_hl = hl end
end
return resolved_hl or name[#name]
end
local function init()
local config = vim.g.fff or {}
local default_config = {
base_path = vim.fn.getcwd(),
prompt = '🪿 ',
title = 'FFFiles',
max_results = 100,
max_threads = 4,
lazy_sync = true, -- set to false if you want file indexing to start on open
prompt_vim_mode = false, -- set to true to enable vim-mode in the prompt: <Esc> leaves insert for normal mode bindings (also allows <leader>p or <leader>l to jump around) the second <Esc> closes the picker
wrap_around = false, -- set to true to wrap cursor to the opposite end when reaching the first/last item
follow_symlinks = false, -- set to true to follow symbolic links during file indexing
-- Allow fff in the user's $HOME director.
enable_home_dir_scanning = true,
-- Allow fff in a filesystem root (e.g. `/`, `C:\`)
enable_fs_root_scanning = false,
layout = {
height = 0.8,
width = 0.8,
prompt_position = 'bottom', -- or 'top'
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
preview_size = 0.5,
-- Border style for the picker windows: 'single', 'double', 'rounded',
-- 'solid', 'shadow' or 'none'. Leave unset (nil) to follow the global
-- `vim.o.winborder` setting.
border = nil,
flex = { -- set to nil to disable flex layout
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
wrap = 'top', -- position to use when screen is narrower than size
},
-- Minimum list height required to render the preview. When the available
-- list area would drop below this on small terminals, the preview is
-- auto-hidden so the file list stays usable. Set to 0 to disable.
min_list_height = 10,
show_scrollbar = true, -- Show scrollbar for pagination
-- How to shorten long directory paths in the file list:
-- 'middle' (default): always uses dots (a/./b, a/../b, a/.../b)
-- 'middle_number' uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
-- and numbers for 4+ (a/.4./b, a/.5./b)
-- 'end': truncates from the end, keeps the start (home/user/projects)
-- 'start': truncates from the start, keeps the end (.../parts/ai_extracted)
path_shorten_strategy = 'middle',
},
preview = {
enabled = true,
max_size = 10 * 1024 * 1024, -- Do not try to read files larger than 10MB
chunk_size = 8192, -- Bytes per chunk for dynamic loading (8kb - fits ~100-200 lines)
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
line_numbers = false,
cursorlineopt = 'both',
wrap_lines = false,
filetypes = {
svg = { wrap_lines = true },
markdown = { wrap_lines = true },
text = { wrap_lines = true },
},
},
keymaps = {
close = '<Esc>',
select = '<CR>',
select_split = '<C-s>',
select_vsplit = '<C-v>',
select_tab = '<C-t>',
-- you can assign multiple keys to any action
move_up = { '<Up>', '<C-p>' },
move_down = { '<Down>', '<C-n>' },
preview_scroll_up = '<C-u>',
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
-- grep mode: cycle between plain text, regex, and fuzzy search
cycle_grep_modes = '<S-Tab>',
-- grep mode only: jump cursor to first item of next/prev file group
grep_jump_to_next_file = { '<C-A-n>', '<A-Down>' },
grep_jump_to_prev_file = { '<C-A-p>', '<A-Up>' },
-- goes to the previous query in history
cycle_previous_query = '<C-Up>',
-- goes to the next query in history (forward)
cycle_forward_query = '<C-Down>',
-- multi-select keymaps for quickfix
toggle_select = '<Tab>',
send_to_quickfix = '<C-q>',
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
focus_list = '<leader>l',
focus_preview = '<leader>p',
},
hl = {
border = 'FloatBorder',
normal = 'NormalFloat',
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
cursor = fallback_hl({ 'CursorLine', 'Visual' }),
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
scrollbar = 'Comment',
directory_path = 'Comment',
-- Multi-select highlights
selected = 'FFFSelected',
selected_active = 'FFFSelectedActive',
-- Git text highlights for file names
git_staged = 'FFFGitStaged',
git_modified = 'FFFGitModified',
git_deleted = 'FFFGitDeleted',
git_renamed = 'FFFGitRenamed',
git_untracked = 'FFFGitUntracked',
git_ignored = 'FFFGitIgnored',
-- Git sign/border highlights
git_sign_staged = 'FFFGitSignStaged',
git_sign_modified = 'FFFGitSignModified',
git_sign_deleted = 'FFFGitSignDeleted',
git_sign_renamed = 'FFFGitSignRenamed',
git_sign_untracked = 'FFFGitSignUntracked',
git_sign_ignored = 'FFFGitSignIgnored',
-- Git sign selected highlights
git_sign_staged_selected = 'FFFGitSignStagedSelected',
git_sign_modified_selected = 'FFFGitSignModifiedSelected',
git_sign_deleted_selected = 'FFFGitSignDeletedSelected',
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
-- Grep highlights
grep_match = 'IncSearch', -- Highlight for matched text in grep results
grep_line_number = 'LineNr', -- Highlight for :line:col location
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
-- Cross-mode suggestion highlights
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
-- File info panel highlights
file_info_section = 'FFFFileInfoSection', -- Section header label (e.g. "file", "score")
file_info_separator = 'FFFFileInfoSeparator', -- Dash dividers used like a border
file_info_label = 'FFFFileInfoLabel', -- Row labels (Size, Type, Git, ...)
file_info_value = 'FFFFileInfoValue', -- Plain values
file_info_value_dim = 'FFFFileInfoValueDim', -- Tertiary values, separators inside rows
file_info_size = 'FFFFileInfoSize', -- File size value
file_info_type = 'FFFFileInfoType', -- Filetype value
file_info_path = 'FFFFileInfoPath', -- Full path value
file_info_total_score = 'FFFFileInfoTotalScore', -- Total score (bold)
file_info_match_type = 'FFFFileInfoMatchType', -- match_type label (bold)
file_info_score_pos = 'FFFFileInfoScorePos', -- Positive score components
file_info_score_neg = 'FFFFileInfoScoreNeg', -- Negative score components / penalties
-- Per-window 'winhighlight' overrides. When nil, falls back to a combination of `normal`, `border`, and `title` above.
-- Accepts either a string applied to every picker window, or a table with optional `prompt`, `list`, `preview`, `file_info` keys.
-- Example: `winhl = 'Normal:NormalFloat,FloatBorder:FloatBorder,FloatTitle:Title'`
-- Example: `winhl = { prompt = 'Normal:Pmenu,...', list = 'Normal:NormalFloat,...' }`
winhl = nil,
},
-- Store file open frecency
frecency = {
enabled = true,
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
},
-- Store successfully opened queries with respective matches
history = {
enabled = true,
db_path = vim.fn.stdpath('data') .. '/fff_queries',
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
},
select = {
--- Returns winid to open the file in. Return nil to open in the invoking
--- window. Default retargets when the invoking window can't host a file
--- buffer (special buftype, non-modifiable, or winfixbuf).
--- @param current_buf integer
--- @param action FffSelectAction
--- @return integer|nil
select_window = function(current_buf, action)
if action ~= 'edit' then return nil end
local current_win = vim.api.nvim_get_current_win()
local buftype = vim.api.nvim_get_option_value('buftype', { buf = current_buf })
local modifiable = vim.api.nvim_get_option_value('modifiable', { buf = current_buf })
local winfixbuf = require('fff.utils').window_has_winfixbuf(current_win)
if buftype == '' and modifiable and not winfixbuf then return nil end
return require('fff.utils').find_suitable_window()
end,
},
-- Git integration
git = {
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
},
debug = {
enabled = false, -- Show file info panel in preview
show_scores = false, -- Show scores inline in the UI
show_file_info = {
file_info = true, -- Size, type, git status, frecency
score_breakdown = true, -- Total + match type, bonuses, modifiers, penalty
-- Modified + accessed timestamps. Pass a boolean to toggle the
-- whole section, or a table to hide individual rows:
-- timings = { modified = false, accessed = true }
timings = true,
full_path = true, -- Full absolute path at the bottom
},
},
logging = {
enabled = true,
-- Path-shape hint: each nvim startup writes a fresh sibling file
-- `<stem>+<UTC-timestamp>+<pid>.<ext>` next to this path. The literal
-- path itself is never written to — multiple concurrent nvim instances
-- get their own per-pid file with no locking.
log_file = vim.fn.stdpath('log') .. '/fff.log',
log_level = 'info',
-- How many session log files to retain. Newest are kept, older are
-- pruned on the next startup. Set to 0 to disable retention.
retain_runs = 20,
},
-- find_files settings
file_picker = {
current_file_label = '(current)',
},
-- grep settings
grep = {
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
smart_case = true, -- Case-insensitive unless query has uppercase
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
trim_whitespace = false, -- Strip leading whitespace from matched lines (useful for cleaner display)
-- Treat filename-like tokens (e.g. `score.rs`, `src/main.rs`) in a grep query as a
-- file-path filter, scoping the content search to matching files. When off, such
-- tokens are searched as literal text. A token is a filename if it has a valid-looking
-- extension and no wildcards.
enable_filename_constraint = false,
-- Format string for the line/column location prefix in grep results.
-- Uses vim's printf-style format: %d placeholders for line and column (1-based).
-- Default ':%d:%d' renders as ':356:1'. Use ':%d' for line-only ':356'.
location_format = ':%d:%d',
},
}
local migrated_user_config = handle_deprecated_config(config)
local merged_config = vim.tbl_deep_extend('force', default_config, migrated_user_config)
-- Normalise show_file_info: accept a boolean shorthand or a partial table
local sfi = merged_config.debug and merged_config.debug.show_file_info
local default_sections = { file_info = true, score_breakdown = true, timings = true, full_path = true }
if type(sfi) == 'boolean' then
merged_config.debug.show_file_info = {
file_info = sfi,
score_breakdown = sfi,
timings = sfi,
full_path = sfi,
}
elseif type(sfi) == 'table' then
for k, v in pairs(default_sections) do
if sfi[k] == nil then sfi[k] = v end
end
else
merged_config.debug.show_file_info = default_sections
end
state.config = merged_config
end
--- Setup the file picker with the given configuration
--- @param config FffConfig Configuration options
function M.setup(config) vim.g.fff = config end
--- @return FffConfig the fff configuration
function M.get()
if not state.config then init() end
return state.config
end
--- True when preview rendering is requested by config. Defaults to `true`
--- when `config` (or its `preview` block) is missing so callers don't have
--- to guard against partial state during init.
--- @param config? FffConfig Optional config; falls back to `M.get()` when nil.
--- @return boolean
function M.preview_enabled(config)
config = config or M.get()
if not config or not config.preview then return true end
return config.preview.enabled
end
--- @return boolean state_changed
function M.toggle_debug()
local old_debug_state = state.config.debug.show_scores
state.config.debug.show_scores = not state.config.debug.show_scores
state.config.debug.enabled = state.config.debug.show_scores
local status = state.config.debug.show_scores and 'enabled' or 'disabled'
vim.notify('FFF debug scores ' .. status, vim.log.levels.INFO)
return old_debug_state ~= state.config.debug.show_scores
end
return M
+176
View File
@@ -0,0 +1,176 @@
local fuzzy = require('fff.fuzzy')
if not fuzzy then error('Failed to load fff.fuzzy module. Ensure the Rust backend is compiled and available.') end
local M = {}
---@class fff.core.State
local state = {
---@type boolean
initialized = false,
---@type boolean
file_picker_initialized = false,
}
---@param config table
local function setup_global_autocmds(config)
local group = vim.api.nvim_create_augroup('fff_file_tracking', { clear = true })
if config.frecency.enabled then
vim.api.nvim_create_autocmd({ 'BufEnter' }, {
group = group,
desc = 'Track file access for FFF frecency',
callback = function(args)
local file_path = args.file
if not (file_path and file_path ~= '' and not vim.startswith(file_path, 'term://')) then return end
vim.uv.fs_stat(file_path, function(err, stat)
if err or not stat then return end
vim.uv.fs_realpath(file_path, function(rp_err, real_path)
if rp_err or not real_path then return end
local ok, track_err = pcall(fuzzy.track_access, real_path)
if not ok then
vim.schedule(
function() vim.notify('FFF: Failed to track file access: ' .. tostring(track_err), vim.log.levels.ERROR) end
)
end
end)
end)
end,
})
end
-- make sure that this won't work correctly if autochdir plugins are enabled
-- using a pure :cd command but will work using lua api or :e command
vim.api.nvim_create_autocmd('DirChanged', {
group = group,
callback = function()
-- Window-local `:lcd` / `:tcd` are per-window — they don't change the
-- effective project root for the picker, so bail before touching
-- anything else.
if vim.v.event.scope == 'window' then return end
if not state.initialized then return end
local new_cwd = vim.v.event.cwd
if not new_cwd or new_cwd == '' then return end
-- Canonicalize both sides before comparing. `vim.v.event.cwd` is
-- whatever the caller passed to `:cd` (often unexpanded, sometimes
-- containing `~` or symlinks), while `config.base_path` is the form
-- the picker was last re-indexed against (post-`expand`). Without
-- resolving symlinks + ensuring an absolute path, trivially
-- equivalent paths compare as different (`/private/var/x` vs
-- `/var/x` on macOS, resolved-vs-unresolved symlinks from LSP root
-- detection, etc.) and every such mismatch schedules a 450k-file
-- reindex through the Rust side.
local function canonicalize(p)
if not p or p == '' then return p end
local abs = vim.fn.fnamemodify(vim.fn.expand(p), ':p')
-- `:p` leaves a trailing slash on directories — strip for
-- comparison stability.
abs = abs:gsub('/+$', '')
local ok, resolved = pcall(vim.fn.resolve, abs)
return (ok and resolved ~= '') and resolved or abs
end
local new_canonical = canonicalize(new_cwd)
local base_canonical = canonicalize(config.base_path)
if new_canonical == base_canonical then return end
vim.schedule(function()
local change_ok, err = pcall(M.change_indexing_directory, new_canonical)
if not change_ok then
vim.notify('FFF: Failed to change indexing directory: ' .. tostring(err), vim.log.levels.ERROR)
end
end)
end,
desc = 'Automatically sync FFF directory changes',
})
end
--- @return boolean
M.is_file_picker_initialized = function() return state.file_picker_initialized end
--- Change the base directory for the file picker. Triggers a reindex on the
--- Rust side and updates `config.base_path` so subsequent `:cd` events compare
--- against the new root.
--- @param new_path string New directory path to use as base
--- @return boolean ok `true` if the reindex was scheduled, `false` otherwise
M.change_indexing_directory = function(new_path)
if not new_path or new_path == '' then
vim.notify('Directory path is required', vim.log.levels.ERROR)
return false
end
local expanded_path = vim.fn.expand(new_path)
if vim.fn.isdirectory(expanded_path) ~= 1 then
vim.notify('Directory does not exist: ' .. expanded_path, vim.log.levels.ERROR)
return false
end
local fff_rust = M.ensure_initialized()
local config = require('fff.conf').get()
local ok, err = pcall(fff_rust.restart_index_in_path, expanded_path, {
follow_symlinks = config.follow_symlinks,
enable_fs_root_scanning = config.enable_fs_root_scanning,
enable_home_dir_scanning = config.enable_home_dir_scanning,
enable_filename_constraint = config.grep and config.grep.enable_filename_constraint,
})
if not ok then
vim.notify('Failed to change directory: ' .. err, vim.log.levels.ERROR)
return false
end
require('fff.conf').get().base_path = expanded_path
return true
end
M.ensure_initialized = function()
if state.initialized then return fuzzy end
state.initialized = true
local config = require('fff.conf').get()
if config.logging.enabled then
local log_success, log_error =
pcall(fuzzy.init_tracing, config.logging.log_file, config.logging.log_level, config.logging.retain_runs)
if log_success then
M.log_file_path = log_error
else
vim.notify('Failed to initialize logging: ' .. (tostring(log_error) or 'unknown error'), vim.log.levels.WARN)
end
end
local frecency_db_path = config.frecency.db_path or (vim.fn.stdpath('cache') .. '/fff_frecency')
local history_db_path = config.history.db_path or (vim.fn.stdpath('data') .. '/fff_history')
local ok, result = pcall(fuzzy.init_db, frecency_db_path, history_db_path, true)
if not ok then vim.notify('Failed to databases: ' .. tostring(result), vim.log.levels.WARN) end
ok, result = pcall(fuzzy.init_file_picker, config.base_path, {
follow_symlinks = config.follow_symlinks,
enable_fs_root_scanning = config.enable_fs_root_scanning,
enable_home_dir_scanning = config.enable_home_dir_scanning,
enable_filename_constraint = config.grep and config.grep.enable_filename_constraint,
})
if not ok then
vim.notify('Failed to initialize file picker: ' .. tostring(result), vim.log.levels.ERROR)
return fuzzy
end
state.file_picker_initialized = true
setup_global_autocmds(config)
local highlights = require('fff.highlights')
highlights.setup()
vim.api.nvim_create_autocmd('ColorScheme', {
group = vim.api.nvim_create_augroup('fff_highlights', { clear = true }),
callback = function() highlights.setup() end,
desc = 'Re-apply FFF highlights on colorscheme change',
})
return fuzzy
end
return M
+289
View File
@@ -0,0 +1,289 @@
local M = {}
local system = require('fff.utils.system')
local fs_utils = require('fff.utils.fs')
local fff_version = require('fff.utils.version')
local GITHUB_REPO = 'dmtrKovalenko/fff.nvim'
local function get_binary_dir(plugin_dir) return plugin_dir .. '/../target/release' end
local function get_binary_path(plugin_dir)
local binary_dir = get_binary_dir(plugin_dir)
local extension = system.get_lib_extension()
return binary_dir .. '/libfff_nvim.' .. extension
end
local function binary_exists(plugin_dir)
local binary_path = get_binary_path(plugin_dir)
local stat = vim.uv.fs_stat(binary_path)
if stat and stat.type == 'file' then return true end
-- On Windows the rename over a loaded DLL fails, so a verified binary may be
-- left at binary_path .. '.tmp'. Promote it now that the old session is gone.
local tmp_path = binary_path .. '.tmp'
local tmp_stat = vim.uv.fs_stat(tmp_path)
if tmp_stat and tmp_stat.type == 'file' then
-- Verify the .tmp is a valid library before promoting it, in case the
-- process was killed between the loadlib check and the rename attempt
-- during a previous download, leaving a corrupt or partial .tmp on disk.
local loader = package.loadlib(tmp_path, 'luaopen_fff_nvim')
if not loader then
vim.uv.fs_unlink(tmp_path)
return false
end
local ok = vim.uv.fs_rename(tmp_path, binary_path)
return ok ~= nil
end
return false
end
local function download_file(url, output_path, opts, callback)
opts = opts or {}
local dir = vim.fn.fnamemodify(output_path, ':h')
fs_utils.mkdir_recursive(dir, function(mkdir_ok, mkdir_err)
if not mkdir_ok then
callback(false, mkdir_err)
return
end
local curl_args = {
'curl',
'--fail',
'--location',
'--silent',
'--show-error',
'--output',
output_path,
}
if opts.proxy then
table.insert(curl_args, '--proxy')
table.insert(curl_args, opts.proxy)
end
if opts.extra_curl_args then
for _, arg in ipairs(opts.extra_curl_args) do
table.insert(curl_args, arg)
end
end
table.insert(curl_args, url)
vim.system(curl_args, {}, function(result)
if result.code ~= 0 then
callback(false, 'Failed to download: ' .. (result.stderr or 'unknown error'))
return
end
callback(true, nil)
end)
end)
end
local function download_from_github(version, binary_path, opts, callback)
opts = opts or {}
local triple = system.get_triple()
local extension = system.get_lib_extension()
local binary_name = triple .. '.' .. extension
local url = string.format('https://github.com/%s/releases/download/%s/%s', GITHUB_REPO, version, binary_name)
vim.schedule(function()
vim.notify(string.format('Downloading fff.nvim binary for ' .. version), vim.log.levels.INFO)
vim.notify(string.format('Do not open fff until you see a success notification.'), vim.log.levels.WARN)
end)
-- Download to a temp path first so we can validate before replacing the live binary.
-- If we wrote directly to binary_path and the current process already has the old
-- library loaded, package.loadlib() on the same path returns the *cached* handle —
-- meaning a truncated download would pass validation silently.
-- Using a distinct temp path forces dlopen to load the new file for real.
local tmp_path = binary_path .. '.tmp'
download_file(url, tmp_path, {
proxy = opts.proxy,
extra_curl_args = opts.extra_curl_args,
}, function(success, err)
if not success then
vim.uv.fs_unlink(tmp_path)
callback(false, err)
return
end
vim.schedule(function()
-- Validate the downloaded binary by actually loading it (temp path is not yet
-- loaded by this process, so dlopen loads the new file for real and catches
-- truncated or corrupt downloads).
-- Note: package.loadlib returns (nil, error_string) on failure rather than throwing.
local loader, load_err = package.loadlib(tmp_path, 'luaopen_fff_nvim')
if not loader then
vim.uv.fs_unlink(tmp_path)
callback(false, 'Downloaded binary is not valid: ' .. (load_err or 'unknown error'))
return
end
-- Atomically replace the live binary only after successful validation.
-- On Windows the old .dll may be locked by the current process, so rename can
-- fail if fff is already loaded. In that case, leave the verified .tmp on disk
-- so the next Neovim start can pick it up automatically.
local rename_ok, rename_err = vim.uv.fs_rename(tmp_path, binary_path)
if not rename_ok then
if vim.uv.os_uname().sysname:lower():match('windows') then
vim.notify(
'fff.nvim binary downloaded to '
.. tmp_path
.. '.\nThe live binary is locked by the current session — please restart Neovim to apply the update.',
vim.log.levels.WARN
)
callback(true, nil)
else
vim.uv.fs_unlink(tmp_path)
callback(false, 'Failed to install binary: ' .. (rename_err or 'unknown error'))
end
return
end
vim.notify('fff.nvim binary downloaded successfully!', vim.log.levels.INFO)
callback(true, nil)
end)
end)
end
function M.ensure_downloaded(opts, callback)
opts = opts or {}
local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
if binary_exists(plugin_dir) and not opts.force then
callback(true, nil)
return
end
local function on_release_tag(release_tag)
if not release_tag then
callback(false, 'Could not determine target version')
return
end
local binary_path = get_binary_path(plugin_dir)
download_from_github(release_tag, binary_path, opts, callback)
end
if opts.version then
on_release_tag(opts.version)
else
-- plugin_dir is <repo>/lua; parent is the repo root
local repo_root = vim.fn.fnamemodify(plugin_dir, ':h')
-- 1. Try reading the CI-created tag on HEAD (no version computation)
local tag = fff_version.current_release_tag(repo_root)
if tag then
on_release_tag(tag)
return
end
-- 2. No local tag — construct the nightly version (bumps patch so
-- the prerelease is higher than Cargo.toml base in semver)
local info, err = fff_version.resolve(repo_root)
if info then
on_release_tag(info.release_tag)
return
end
callback(false, err or 'Could not determine target version')
end
end
function M.download_binary(callback)
M.ensure_downloaded({ force = true }, function(success, err)
if not success then
if callback then
callback(false, err)
else
vim.schedule(
function()
vim.notify('Failed to download fff.nvim binary: ' .. (err or 'unknown error'), vim.log.levels.ERROR)
end
)
end
return
end
if callback then callback(true, nil) end
end)
end
function M.build_binary(callback)
local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
local has_rustup = vim.fn.executable('rustup') == 1
if not has_rustup then
callback(
false,
'rustup is not found. It is required to build the fff.nvim binary. Install it from https://rustup.rs/'
)
return
end
vim.system({ 'cargo', 'build', '--release' }, { cwd = plugin_dir }, function(result)
if result.code ~= 0 then
callback(false, 'Failed to build rust binary: ' .. (result.stderr or 'unknown error'))
return
end
callback(true, nil)
end)
end
function M.download_or_build_binary()
local done = false
local fatal_error = nil
M.ensure_downloaded({ force = true }, function(download_success, download_error)
if download_success then
done = true
return
end
vim.schedule(
function()
vim.notify(
'Error downloading binary: ' .. (download_error or 'unknown error') .. '\nTrying cargo build --release\n',
vim.log.levels.WARN
)
end
)
M.build_binary(function(build_success, build_error)
if not build_success then
fatal_error = 'Failed to build fff.nvim binary. Build error: ' .. (build_error or 'unknown error')
else
vim.schedule(function() vim.notify('fff.nvim binary built successfully!', vim.log.levels.INFO) end)
end
done = true
end)
end)
-- Block the caller (and keep the Neovim event loop alive) until the entire
-- download-or-build chain finishes. This is critical for lazy.nvim build
-- hooks: lazy returns from the hook immediately after this function returns,
-- and if Neovim exits before the final rename(tmp → libfff_nvim.{dylib,so,dll})
-- executes, the binary is never written to disk. vim.wait pumps the event
-- loop so all vim.system / vim.schedule callbacks can fire.
local timeout_ms = 1000 * 60 * 2 -- 2 minutes
local ok, wait_err = vim.wait(timeout_ms, function() return done end, 100)
if not ok and wait_err == -2 then error('fff.nvim: download_or_build_binary timed out') end
if fatal_error then error(fatal_error) end
end
function M.get_binary_path()
local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
return get_binary_path(plugin_dir)
end
function M.get_binary_cpath_component()
local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
local binary_dir = get_binary_dir(plugin_dir)
local extension = system.get_lib_extension()
return binary_dir .. '/lib?.' .. extension
end
return M
+415
View File
@@ -0,0 +1,415 @@
local M = {}
---@class FFFFileInfoExtmark
---@field row integer
---@field col integer
---@field end_col integer|nil
---@field hl_group string|nil
---@class FFFFileInfoResult
---@field lines string[]
---@field extmarks FFFFileInfoExtmark[]
---@field height integer
---@class FFFFileInfoSections
---@field file_info boolean
---@field score_breakdown boolean
---@field timings boolean
---@field full_path boolean
---@class FFFFileInfoFile
---@field relative_path string
---@field absolute_path string|nil
---@field size_formatted string
---@field filetype string
---@field git_status string
---@field access_frecency_score integer
---@field modification_frecency_score integer
---@field times_opened integer
---@field modified_formatted string
---@field accessed_formatted string
---@class FFFFileInfoScore
---@field total integer
---@field match_type string
---@field base_score integer
---@field filename_bonus integer
---@field special_filename_bonus integer
---@field frecency_boost integer
---@field combo_match_boost integer
---@field distance_penalty integer
---@field current_file_penalty integer
local Builder = {}
Builder.__index = Builder
function Builder.new() return setmetatable({ lines = {}, extmarks = {} }, Builder) end
function Builder:add_line(text) table.insert(self.lines, text or '') end
function Builder:add_hl(row, col, end_col, hl_group)
if not hl_group or hl_group == '' then return end
table.insert(self.extmarks, { row = row, col = col, end_col = end_col, hl_group = hl_group })
end
-- Section header: `─ <label> ` then dashes filling the remaining width.
function Builder:add_section_header(label, hls, width)
local row = #self.lines
local prefix = ''
local left_text = prefix .. label .. ' '
local fill_chars = math.max(1, (width or 0) - vim.fn.strdisplaywidth(left_text))
local fill = string.rep('', fill_chars)
self:add_line(left_text .. fill)
self:add_hl(row, 0, #prefix, hls.file_info_separator)
self:add_hl(row, #prefix, #prefix + #label, hls.file_info_section)
self:add_hl(row, #left_text, #left_text + #fill, hls.file_info_separator)
end
local GIT_HL_KEYS = {
staged_new = 'git_staged',
staged_modified = 'git_staged',
staged_deleted = 'git_staged',
modified = 'git_modified',
deleted = 'git_deleted',
renamed = 'git_renamed',
untracked = 'git_untracked',
ignored = 'git_ignored',
}
local function git_hl(hls, status)
local key = GIT_HL_KEYS[status]
return (key and hls[key]) or hls.file_info_value
end
local GIT_LABELS = {
staged_new = 'staged (new)',
staged_modified = 'staged',
staged_deleted = 'staged (del)',
untracked = 'untracked',
modified = 'modified',
deleted = 'deleted',
renamed = 'renamed',
ignored = 'ignored',
clean = 'clean',
clear = '',
unknown = '?',
}
local function frecency_value(file)
return string.format('acc %d / mod %d', file.access_frecency_score or 0, file.modification_frecency_score or 0)
end
local function signed(n)
n = n or 0
if n > 0 then return '+' .. tostring(n) end
return tostring(n)
end
-- Pad to `width` cols, truncate with '…' if longer.
local function pad(text, width)
text = text or ''
local w = vim.fn.strdisplaywidth(text)
if w == width then return text end
if w < width then return text .. string.rep(' ', width - w) end
if width <= 1 then return text:sub(1, math.max(0, width)) end
return text:sub(1, width - 1) .. ''
end
-- 4-cell grid row: [indent][label1][value1][label2][value2]. `value2_max`
-- clamps the trailing cell so the row doesn't overflow.
local function add_grid_row(b, indent_w, label1_w, value1_w, label2_w, cells, hls_by_cell, value2_max)
local row = #b.lines
local l1 = pad(cells[1] or '', label1_w)
local v1 = pad(cells[2] or '', value1_w)
local l2 = pad(cells[3] or '', label2_w)
local v2_raw = cells[4] or ''
local v2 = (value2_max and value2_max > 0) and pad(v2_raw, value2_max) or v2_raw
b:add_line(string.rep(' ', indent_w) .. l1 .. v1 .. l2 .. v2)
local pos = indent_w
if cells[1] and cells[1] ~= '' then b:add_hl(row, pos, pos + #cells[1], hls_by_cell[1]) end
pos = pos + #l1
if cells[2] and cells[2] ~= '' then b:add_hl(row, pos, pos + #cells[2], hls_by_cell[2]) end
pos = pos + #v1
if cells[3] and cells[3] ~= '' then b:add_hl(row, pos, pos + #cells[3], hls_by_cell[3]) end
pos = pos + #l2
if v2 ~= '' then
local content_len = math.min(#v2_raw, value2_max or math.huge)
b:add_hl(row, pos, pos + content_len, hls_by_cell[4])
end
end
-- hardcoded because specifically optimized to make every section look good
local W = {
indent = 1,
label1 = 6,
value1 = 15,
label2 = 9, -- score/match section trailing label
file_label2 = 8, -- file overview trailing label ("Opened")
timings_label = 10,
timings_inline_width = 62,
}
W.grid_prefix = W.indent + W.label1 + W.value1 + W.label2
local function format_opened(count)
if count == 0 then return 'never' end
if count == 1 then return '1 time last 30 days' end
if count >= 128 then return '128+ times last 30 days' end
return count .. ' times last 30 days'
end
local function render_file_overview_section(b, file, hls, width)
local git_status = file.git_status or 'clean'
local git_value = GIT_LABELS[git_status] or git_status
if git_value == '' then git_value = 'clean' end
local opened_count = file.times_opened or 0
local v2_max = math.max(4, width - W.indent - W.label1 - W.value1 - W.file_label2)
local function row(label1, value1, hl1, label2, value2, hl2)
add_grid_row(
b,
W.indent,
W.label1,
W.value1,
W.file_label2,
{ label1, value1, label2, value2 },
{ hls.file_info_label, hl1, hls.file_info_label, hl2 },
v2_max
)
end
row('Size', file.size_formatted or 'N/A', hls.file_info_size, 'Type', file.filetype or 'text', hls.file_info_type)
row(
'Git',
git_value,
git_hl(hls, git_status),
'Opened',
format_opened(opened_count),
opened_count > 0 and hls.file_info_value or hls.file_info_value_dim
)
end
local function render_score_section(b, score, file, hls, width)
local v2_max = math.max(4, width - W.grid_prefix)
if not score then
add_grid_row(
b,
W.indent,
W.label1,
W.value1,
W.label2,
{ 'Total', 'N/A', '', '' },
{ hls.file_info_label, hls.file_info_value_dim, hls.file_info_label, hls.file_info_value },
v2_max
)
return
end
local total_str = tostring(score.total or 0)
local mt_str = score.match_type or 'unknown'
local frec_str = file and frecency_value(file) or ''
local indent = string.rep(' ', W.indent)
local total_label = 'Total '
local frec_label = ' Frecency '
local left = indent .. total_label .. total_str .. ' ' .. mt_str
local right = (frec_str ~= '') and (frec_label .. frec_str) or ''
local total_w = vim.fn.strdisplaywidth(left) + vim.fn.strdisplaywidth(right)
if total_w > width then
local overflow = total_w - width + 1
local mt_keep = math.max(1, vim.fn.strdisplaywidth(mt_str) - overflow)
mt_str = mt_str:sub(1, mt_keep) .. ''
left = indent .. total_label .. total_str .. ' ' .. mt_str
end
local row = #b.lines
b:add_line(left .. right)
b:add_hl(row, W.indent, W.indent + 5, hls.file_info_label)
local total_byte = W.indent + #total_label
b:add_hl(row, total_byte, total_byte + #total_str, hls.file_info_total_score)
local mt_byte = total_byte + #total_str + 1
b:add_hl(row, mt_byte, mt_byte + #mt_str, hls.file_info_match_type)
if right ~= '' then
local right_byte = #left
b:add_hl(row, right_byte + 2, right_byte + 2 + 8, hls.file_info_label)
b:add_hl(row, right_byte + #frec_label, right_byte + #right, hls.file_info_value)
end
local total_pen = (score.distance_penalty or 0) + (score.current_file_penalty or 0)
local pos_hl = hls.file_info_score_pos
local neg_hl = hls.file_info_score_neg
local val_hl = hls.file_info_value
local function bonus_hl(n) return (n or 0) > 0 and pos_hl or val_hl end
local function mod_hl(n) return (n or 0) >= 0 and pos_hl or neg_hl end
local segments = {
{ 'base ' .. (score.base_score or 0), val_hl },
{ ' +name ' .. (score.filename_bonus or 0), bonus_hl(score.filename_bonus) },
{ ' +special ' .. (score.special_filename_bonus or 0), bonus_hl(score.special_filename_bonus) },
{ ' +frec ' .. signed(score.frecency_boost or 0), mod_hl(score.frecency_boost) },
{ ' +combo ' .. signed(score.combo_match_boost or 0), bonus_hl(score.combo_match_boost) },
{ ' penalty ' .. total_pen, total_pen > 0 and neg_hl or val_hl },
}
local row2 = #b.lines
local line_text = indent
local hl_ranges = {}
for _, seg in ipairs(segments) do
local from = #line_text
line_text = line_text .. seg[1]
table.insert(hl_ranges, { from, #line_text, seg[2] })
end
if vim.fn.strdisplaywidth(line_text) > width then line_text = line_text:sub(1, width - 1) .. '' end
b:add_line(line_text)
for _, r in ipairs(hl_ranges) do
if r[1] < #line_text then b:add_hl(row2, r[1], math.min(r[2], #line_text), r[3]) end
end
end
local function render_score(b, score, file, hls, width)
b:add_section_header('Score', hls, width)
render_score_section(b, score, file, hls, width)
end
-- `timings_opt` may be a boolean or `{ modified = bool, accessed = bool }`.
local function render_timings_section(b, file, hls, width, timings_opt)
local show_modified, show_accessed
if type(timings_opt) == 'table' then
show_modified = timings_opt.modified ~= false
show_accessed = timings_opt.accessed ~= false
else
show_modified = true
show_accessed = true
end
if not show_modified and not show_accessed then return end
b:add_section_header('Timings', hls, width)
local label_hl = hls.file_info_label
local value_hl = hls.file_info_value
local both_inline = show_modified and show_accessed and width >= W.timings_inline_width
if both_inline then
add_grid_row(
b,
W.indent,
W.timings_label,
22,
W.timings_label,
{ 'Modified', file.modified_formatted or 'N/A', 'Accessed', file.accessed_formatted or 'N/A' },
{ label_hl, value_hl, label_hl, value_hl }
)
return
end
if show_modified then
add_grid_row(
b,
W.indent,
W.timings_label,
19,
0,
{ 'Modified', file.modified_formatted or 'N/A', '', '' },
{ label_hl, value_hl, label_hl, value_hl }
)
end
if show_accessed then
add_grid_row(
b,
W.indent,
W.timings_label,
19,
0,
{ 'Accessed', file.accessed_formatted or 'N/A', '', '' },
{ label_hl, value_hl, label_hl, value_hl }
)
end
end
-- Manual path split — buffer is nowrap so wrap would misalign extmarks.
local function render_path_section(b, file, hls, width)
b:add_section_header('Path', hls, width)
local path = file.relative_path or file.absolute_path or ''
local indent = string.rep(' ', W.indent)
local content_width = math.max(10, width - W.indent - 1)
local path_hl = hls.file_info_path
if #path <= content_width then
local row = #b.lines
b:add_line(indent .. path)
b:add_hl(row, W.indent, W.indent + #path, path_hl)
else
local first = path:sub(1, content_width)
local rest = path:sub(content_width + 1)
if #rest > content_width then rest = '' .. rest:sub(-content_width + 1) end
local r1 = #b.lines
b:add_line(indent .. first)
b:add_hl(r1, W.indent, W.indent + #first, path_hl)
local r2 = #b.lines
b:add_line(indent .. rest)
b:add_hl(r2, W.indent, W.indent + #rest, path_hl)
end
end
---@class FFFFileInfoInput
---@field file FFFFileInfoFile
---@field score FFFFileInfoScore|nil
---@field width integer
---@field sections FFFFileInfoSections
---@field hls table The `hl` block from FffConfig.
---@param input FFFFileInfoInput
---@return FFFFileInfoResult
function M.build(input)
local width = input.width or 80
local sections = input.sections or {}
local hls = input.hls
local file = input.file
local b = Builder.new()
if sections.file_info ~= false then render_file_overview_section(b, file, hls, width) end
if sections.score_breakdown ~= false then render_score(b, input.score, file, hls, width) end
if sections.timings ~= false then render_timings_section(b, file, hls, width, sections.timings) end
if sections.full_path ~= false then render_path_section(b, file, hls, width) end
return { lines = b.lines, extmarks = b.extmarks, height = #b.lines }
end
--- @param sections table|boolean
--- @param panel_width integer
--- @return integer
function M.calculate_required_height(sections, panel_width)
if sections == false then return 0 end
local s = sections
if type(s) == 'boolean' then
s = { file_info = s, score_breakdown = s, timings = s, full_path = s }
elseif type(s) ~= 'table' then
s = {}
end
local total = 0
if s.file_info ~= false then total = total + 2 end
if s.score_breakdown ~= false then total = total + 3 end
local t = s.timings
if t ~= false and t ~= nil then
local show_modified, show_accessed
if type(t) == 'table' then
show_modified = t.modified ~= false
show_accessed = t.accessed ~= false
else
show_modified = true
show_accessed = true
end
local visible = (show_modified and 1 or 0) + (show_accessed and 1 or 0)
if visible > 0 then
local inline = visible == 2 and (panel_width or 0) >= W.timings_inline_width
total = total + 1 + (inline and 1 or visible)
end
end
if s.full_path ~= false and s.full_path ~= nil then total = total + 2 end
return total
end
return M
+91
View File
@@ -0,0 +1,91 @@
local M = {}
local icon_providers = {
'nvim-web-devicons',
'mini.icons',
}
M.provider = nil
M.provider_name = nil
M.setup_attempted = false
M.setup_failed = false
function M.setup()
if M.provider_name then return true end
if M.setup_failed then return false end
M.setup_attempted = true
for _, provider_name in ipairs(icon_providers) do
local ok, provider = pcall(require, provider_name)
if ok then
M.provider = provider
M.provider_name = provider_name
return true
end
end
M.setup_failed = true
return false
end
--- Get icon for a directory
--- @param dirname string The directory name
--- @return string|nil, string|nil Icon and highlight group (nil if no provider)
function M.get_directory_icon(dirname)
if not M.setup() then return nil, nil end
local basename = vim.fn.fnamemodify(dirname, ':t')
if M.provider_name == 'nvim-web-devicons' then
if M.provider.get_icon then
local icon, hl = M.provider.get_icon(basename, nil, { default = true })
if icon and icon ~= '' and hl then return icon, hl end
end
elseif M.provider_name == 'mini.icons' then
if M.provider.get then
local icon, hl, _ = M.provider.get('directory', basename)
if icon and icon ~= '' and hl then return icon, hl end
end
end
return nil, nil
end
--- Get icon for a file
--- @param filename string The filename
--- @param extension string The file extension (without dot)
--- @param is_directory boolean Whether this is a directory
--- @return string|nil, string|nil Icon and highlight group (nil if no provider)
function M.get_icon(filename, extension, is_directory)
if not M.setup() then return nil, nil end
if is_directory then return M.get_directory_icon(filename) end
if M.provider_name == 'nvim-web-devicons' then
local icon, hl = M.provider.get_icon(filename, extension, { default = true })
if icon and icon ~= '' and hl then return icon, hl end
elseif M.provider_name == 'mini.icons' then
local icon, hl, _ = M.provider.get('file', filename)
if icon and icon ~= '' and hl then return icon, hl end
end
return nil, nil
end
--- Check if directories are supported by current provider
--- @return boolean True if directory icons are supported
function M.supports_directories() return M.setup() and M.provider_name ~= nil end
--- Get provider info for debugging
--- @return table Provider information
function M.get_provider_info()
M.setup()
return {
name = M.provider_name or 'none',
available = M.provider ~= nil,
supports_directories = M.supports_directories(),
}
end
return M
+264
View File
@@ -0,0 +1,264 @@
local utils = require('fff.utils')
local M = {}
local function get_main_config()
local main = require('fff.main')
return main.config
end
local active_placements = {} ---@type table<number, any>
local loading_jobs = {} ---@type table<number, {metadata_job?: any}>
local function reserve_image_buffer_space(bufnr, metadata_lines_count)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
local win = vim.fn.bufwinid(bufnr)
local buffer_height = win ~= -1 and vim.api.nvim_win_get_height(win) or 24
local lines_for_image = math.max(buffer_height - metadata_lines_count - 2, 5)
local buffer_lines = {}
for _ = 1, metadata_lines_count do
table.insert(buffer_lines, '')
end
for _ = 1, lines_for_image do
table.insert(buffer_lines, '')
end
local was_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, buffer_lines)
vim.api.nvim_set_option_value('modifiable', was_modifiable, { buf = bufnr })
return metadata_lines_count or 2
end
local function update_metadata_lines(bufnr, info_lines, reserved_lines_count)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
reserved_lines_count = reserved_lines_count or 2
local metadata_lines = {}
for i = 1, reserved_lines_count do
metadata_lines[i] = info_lines[i] or ''
end
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, reserved_lines_count, false, metadata_lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
end
local function identify_image_lines_async(file_path, bufnr, callback)
local stat = vim.uv.fs_stat(file_path)
local size_str = stat and utils.format_file_size(stat.size) or 'Unknown'
local initial_info_lines = { ' Size: ' .. size_str }
if vim.fn.executable('identify') == 0 then
callback(initial_info_lines)
return
end
callback(initial_info_lines)
local config = get_main_config()
local format_str = config and config.preview and config.preview.imagemagick_info_format_str
or '%m: %wx%h, %[colorspace], %q-bit'
local cmd = { 'identify', '-format', format_str, file_path }
-- Cancel any previous metadata job
if loading_jobs[bufnr] and loading_jobs[bufnr].metadata_job then
pcall(loading_jobs[bufnr].metadata_job.kill, loading_jobs[bufnr].metadata_job, 9)
end
loading_jobs[bufnr] = loading_jobs[bufnr] or {}
loading_jobs[bufnr].metadata_job = vim.system(cmd, { text = true }, function(result)
vim.schedule(function()
if not vim.api.nvim_buf_is_valid(bufnr) then return end
local enhanced_info_lines = vim.deepcopy(initial_info_lines)
if result.code == 0 and result.stdout and result.stdout ~= '' then
local magick_info = ' ' .. result.stdout:gsub('\n', '')
table.insert(enhanced_info_lines, magick_info)
end
callback(enhanced_info_lines)
end)
end)
end
local IMAGE_EXTENSIONS = {
'.jpg',
'.jpeg',
'.png',
'.gif',
'.bmp',
'.tiff',
'.tif',
'.webp',
'.ico',
'.pdf',
'.ps',
'.eps',
'.heic',
'.avif',
}
--- @param file_path string Path to the file
--- @return boolean True if file is an image
function M.is_image(file_path)
local ext = string.lower(vim.fn.fnamemodify(file_path, ':e'))
if ext == '' then return false end
for _, image_ext in ipairs(IMAGE_EXTENSIONS) do
if '.' .. ext == image_ext then return true end
end
return false
end
function M.clear_buffer_images(bufnr)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
if loading_jobs[bufnr] then
if loading_jobs[bufnr].metadata_job then
pcall(loading_jobs[bufnr].metadata_job.kill, loading_jobs[bufnr].metadata_job, 9)
end
loading_jobs[bufnr] = nil
end
if active_placements[bufnr] then
pcall(active_placements[bufnr].close, active_placements[bufnr])
active_placements[bufnr] = nil
end
local ok, snacks = pcall(require, 'snacks')
if ok and snacks.image and snacks.image.placement then pcall(snacks.image.placement.clean, bufnr) end
pcall(vim.api.nvim_buf_clear_namespace, bufnr, -1, 0, -1)
end
--- Load metadata of the image, displays it and display image in paralallel
--- Fully asynchronous
--- @param file_path string Path to the image file
--- @param bufnr number Buffer number to display in
--- @return boolean
function M.display_image(file_path, bufnr)
local wins = vim.fn.win_findbuf(bufnr)
for _, win in ipairs(wins) do
vim.api.nvim_set_option_value('number', false, { win = win })
end
local reserved_metadata_lines = reserve_image_buffer_space(bufnr, 2)
local image_content_starts_at_line = reserved_metadata_lines + 1
identify_image_lines_async(
file_path,
bufnr,
function(final_info_lines) update_metadata_lines(bufnr, final_info_lines, reserved_metadata_lines) end
)
local ok, snacks = pcall(require, 'snacks')
if not ok then
local error_lines = {
'⚠ Image Preview Unavailable',
'',
'snacks.nvim plugin is not installed or not available.',
}
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
return false
end
if not snacks.image.supports_terminal() then
local error_lines = {
'⚠ Image Preview Unavailable',
'',
'Terminal does not support image preview.',
'Please use a terminal that supports images, such as Kitty, Wezterm or Alacritty.',
}
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
return false
end
if not snacks.image.supports_file(file_path) then
local error_lines = {
'⚠ Unsupported Image Format',
'',
'File format is not supported for image preview.',
'File: ' .. vim.fn.fnamemodify(file_path, ':t'),
}
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
return false
end
if snacks.image and snacks.image.placement then
M.clear_buffer_images(bufnr)
vim.schedule(function()
local success, placement = pcall(snacks.image.placement.new, bufnr, file_path, {
pos = { image_content_starts_at_line, 1 },
inline = true,
fit = 'contain',
auto_resize = true,
})
if success and placement then active_placements[bufnr] = placement end
end)
identify_image_lines_async(
file_path,
bufnr,
function(final_info_lines) update_metadata_lines(bufnr, final_info_lines, reserved_metadata_lines) end
)
return true
end
return false
end
--- Check image preview availability status
--- @return table status { available: boolean, snacks_available: boolean, snacks_image_available: boolean, terminal_supported: boolean, error: string|nil }
function M.get_preview_status()
local status = {
available = false,
snacks_available = false,
snacks_image_available = false,
terminal_supported = false,
error = nil,
}
local ok, snacks = pcall(require, 'snacks')
if not ok then
status.error = 'snacks.nvim not installed'
return status
end
status.snacks_available = true
if not snacks.image then
status.error = 'snacks.image module not available'
return status
end
status.snacks_image_available = true
if not snacks.image.supports_terminal or not snacks.image.supports_terminal() then
status.error = 'terminal does not support image display'
return status
end
status.terminal_supported = true
status.available = true
return status
end
return M
+212
View File
@@ -0,0 +1,212 @@
--- FFF.nvim File Picker - High-performance file picker for Neovim
--- Uses advanced fuzzy search algorithm with frecency scoring
local M = {}
local fuzzy = require('fff.core').ensure_initialized()
M.state = {
initialized = false,
base_path = nil,
last_scan_time = 0,
}
function M.setup()
local config = require('fff.conf').get()
M.state.initialized = true
M.state.base_path = config.base_path
return true
end
--- Trigger scan of files in the current directory (asynchronous)
function M.scan_files()
if not M.state.initialized then return end
local ok, result = pcall(fuzzy.scan_files)
if not ok then
vim.notify('Failed to trigger file scan: ' .. tostring(result), vim.log.levels.ERROR)
return
end
M.state.last_scan_time = os.time()
end
--- Search files with fuzzy matching using blink.cmp's advanced algorithm
--- Results are always returned in descending order (best scores first)
--- @param query string Search query
--- @param max_results number|nil Maximum number of results (optional)
--- @param max_threads number|nil Maximum number of threads (optional)
--- @param current_file string|nil Path to current file to deprioritize (optional)
--- @param min_combo_count_override number|nil Optional override for min_combo_count (nil uses config)
--- @return table List of matching files
function M.search_files(query, current_file, max_results, max_threads, min_combo_count_override)
-- Delegate to paginated version with offset=0 and limit=max_results
return M.search_files_paginated(query, current_file, max_threads, min_combo_count_override, 0, max_results)
end
--- Search files with pagination support
--- Results are always returned in descending order (best scores first)
--- @param query string Search query
--- @param current_file string|nil Path to current file to deprioritize (optional)
--- @param max_threads number|nil Maximum number of threads to use
--- @param min_combo_count_override number|nil Optional override for min_combo_count (nil uses config)
--- @param page_index number Page index (0-based: 0, 1, 2, ...)
--- @param page_size number|nil Items per page (nil uses config default)
--- @return table List of matching files
function M.search_files_paginated(query, current_file, max_threads, min_combo_count_override, page_index, page_size)
local config = require('fff.conf').get()
if not M.state.initialized then return {} end
max_threads = max_threads or config.max_threads or 4
page_index = page_index or 0
page_size = page_size or 0
local min_combo_count = min_combo_count_override
if min_combo_count == nil then min_combo_count = config.history and config.history.min_combo_count or 3 end
local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100
-- Convert page_index to offset (Rust expects offset in items, not page number)
local offset = page_index * page_size
local ok, search_result = pcall(
fuzzy.fuzzy_search_files,
query,
max_threads,
current_file,
combo_boost_score_multiplier,
min_combo_count,
offset,
page_size
)
if not ok then
vim.notify('Failed to search files: ' .. tostring(search_result), vim.log.levels.ERROR)
return {}
end
M.state.last_search_result = search_result
return search_result.items
end
--- Get the last search result metadata
--- @return table Search metadata with total_matched and total_files
function M.get_search_metadata()
if not M.state.last_search_result then return { total_matched = 0, total_files = 0 } end
return {
total_matched = M.state.last_search_result.total_matched,
total_files = M.state.last_search_result.total_files,
}
end
--- Get location data from the last search result
--- @return table|nil Location data if available
function M.get_search_location()
if not M.state.last_search_result then return nil end
return M.state.last_search_result.location
end
--- Get score information for a file by index (1-based)
--- @param index number The index of the file in the last search results
--- @return table|nil Score information or nil if not available
function M.get_file_score(index)
if not M.state.last_search_result or not M.state.last_search_result.scores then return nil end
return M.state.last_search_result.scores[index]
end
--- Record file access for frecency tracking
--- @param file_path string Path to the file that was accessed
function M.track_access(file_path)
if not M.state.initialized then return end
local ok, result = pcall(fuzzy.track_access, file_path)
if not ok then vim.notify('Failed to record file access: ' .. tostring(result), vim.log.levels.WARN) end
end
--- Get file content for preview
--- @param file_path string Path to the file
--- @return string|nil File content or nil if failed
function M.get_file_preview(file_path)
local preview = require('fff.file_picker.preview')
-- Create a temporary buffer to get the preview
local temp_buf = vim.api.nvim_create_buf(false, true)
local success = preview.preview(file_path, temp_buf)
if not success then
vim.api.nvim_buf_delete(temp_buf, { force = true })
return nil
end
local lines = vim.api.nvim_buf_get_lines(temp_buf, 0, -1, false)
vim.api.nvim_buf_delete(temp_buf, { force = true })
return table.concat(lines, '\n')
end
--- Check if file picker is initialized
--- @return boolean
function M.is_initialized() return M.state.initialized end
--- Get scan progress information
--- @return table Progress information with scanned_files_count, is_scanning
function M.get_scan_progress()
if not M.state.initialized then return { total_files = 0, scanned_files_count = 0, is_scanning = false } end
local ok, result = pcall(fuzzy.get_scan_progress)
if not ok then
vim.notify('Failed to get scan progress: ' .. tostring(result), vim.log.levels.WARN)
return { scanned_files_count = 0, is_scanning = false }
end
return result
end
--- Refresh git status on cached files (call after git status loading completes)
--- @return table List of files with updated git status
function M.refresh_git_status()
if not M.state.initialized then return {} end
local ok, result = pcall(fuzzy.refresh_git_status)
if not ok then
vim.notify('Failed to refresh git status: ' .. tostring(result), vim.log.levels.WARN)
return {}
end
-- Update our cache
return result
end
--- Stop background git status monitoring
--- @return boolean Success status
function M.stop_background_monitor()
if not M.state.initialized then return false end
local ok, result = pcall(fuzzy.stop_background_monitor)
if not ok then
vim.notify('Failed to stop background monitor: ' .. tostring(result), vim.log.levels.WARN)
return false
end
return result
end
--- Wait for initial scan to complete
--- @param timeout_ms number Optional timeout in milliseconds (default 5000)
--- @return boolean True if scan completed, false if timed out
function M.wait_for_initial_scan(timeout_ms)
if not M.state.initialized then return false end
local ok, result = pcall(fuzzy.wait_for_initial_scan, timeout_ms)
if not ok then
vim.notify('Failed to wait for initial scan: ' .. tostring(result), vim.log.levels.WARN)
return false
end
return result
end
--- Get current state
--- @return table
function M.get_state() return M.state end
return M
+946
View File
@@ -0,0 +1,946 @@
local utils = require('fff.utils')
local file_picker = require('fff.file_picker')
local image = require('fff.file_picker.image')
local location_utils = require('fff.location_utils')
local rust = require('fff.rust')
local file_info_renderer = require('fff.file_picker.file_info')
local M = {}
-- Namespace dedicated to the file info panel highlights.
M.file_info_ns = vim.api.nvim_create_namespace('fff_file_info')
local function set_buffer_lines(bufnr, lines)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
end
local function append_buffer_lines(bufnr, lines)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
if not lines or #lines == 0 then return end
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
local current_lines = vim.api.nvim_buf_line_count(bufnr)
vim.api.nvim_buf_set_lines(bufnr, current_lines, current_lines, false, lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
end
local function show_preview_unavailable(bufnr) set_buffer_lines(bufnr, { 'No preview available' }) end
local function set_preview_filetype(bufnr, filetype)
-- FileType hooks can throw or mutate global v:errmsg during preview setup.
-- pcall lets us restore it; callers treat failures as preview-local.
local errmsg = vim.v.errmsg
local ok, err = pcall(vim.api.nvim_set_option_value, 'filetype', filetype, { buf = bufnr })
vim.v.errmsg = errmsg
return ok, err
end
local function find_existing_buffer(file_path)
local abs_path = vim.fn.resolve(vim.fn.fnamemodify(file_path, ':p'))
for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(bufnr) then
local buf_name = vim.api.nvim_buf_get_name(bufnr)
if buf_name ~= '' then
local buf_path = vim.fn.resolve(vim.fn.fnamemodify(buf_name, ':p'))
if buf_path == abs_path then return bufnr end
end
end
end
return nil
end
local function cleanup_file_operation()
if M.state.file_operation then
if M.state.file_operation.fd then pcall(vim.uv.fs_close, M.state.file_operation.fd) end
M.state.file_operation = nil
end
end
--- Process raw chunk data into complete lines, joining any leftover bytes
--- from the previous chunk and storing any trailing partial line for the next.
--- @param data string Raw chunk data
--- @return string[] Complete lines (may be empty if the entire chunk is a partial line)
local function split_chunk_with_remainder(data)
if not data or data == '' then return {} end
local fo = M.state.file_operation
local prefix = fo and fo.remainder or ''
local combined = prefix .. data
local lines = vim.split(combined, '\n', { plain = true })
if combined:sub(-1) ~= '\n' then
-- Data doesn't end on a line boundary: last element is a partial line
local partial = table.remove(lines) or ''
if fo then fo.remainder = partial end
else
-- Data ends on a line boundary: remove the trailing empty element
if #lines > 0 and lines[#lines] == '' then table.remove(lines) end
if fo then fo.remainder = '' end
end
return lines
end
local function init_dynamic_loading_async(file_path, callback)
cleanup_file_operation()
M.state.loaded_lines = 0
M.state.total_file_lines = nil
M.state.has_more_content = true
M.state.is_loading = false
local generation = M.state.preview_generation
vim.uv.fs_open(file_path, 'r', 438, function(err, fd)
vim.schedule(function()
-- Stale callback: preview moved on to a different file
if M.state.preview_generation ~= generation then
if fd then pcall(vim.uv.fs_close, fd) end
return
end
if err or not fd then
callback(false, 'Failed to open file: ' .. (err or 'unknown error'))
return
end
M.state.file_operation = {
fd = fd,
file_path = file_path,
position = 0,
remainder = '',
}
callback(true)
end)
end)
end
local function load_forward_chunk_async(target_size, callback)
if not M.state.file_operation or not M.state.file_operation.fd then
callback('', 'No file handle available')
return
end
M.state.is_loading = true
local chunk_size = target_size or (M.config.chunk_size or 16384)
local generation = M.state.preview_generation
vim.uv.fs_read(M.state.file_operation.fd, chunk_size, M.state.file_operation.position, function(err, data)
vim.schedule(function()
-- Stale callback: a newer preview has started, discard this result
if M.state.preview_generation ~= generation then return end
M.state.is_loading = false
if err then
callback('', 'Read error: ' .. err)
return
end
if not data or #data == 0 then
M.state.has_more_content = false
-- Flush any remaining partial line as the final piece of data
local final_remainder = M.state.file_operation and M.state.file_operation.remainder or ''
cleanup_file_operation()
if final_remainder ~= '' then
callback(final_remainder .. '\n', nil)
else
callback('', nil)
end
return
end
if M.state.file_operation then M.state.file_operation.position = M.state.file_operation.position + #data end
callback(data, nil)
end)
end)
end
local function load_next_chunk_async(chunk_size, callback)
if not M.state.file_operation or not M.state.has_more_content or M.state.is_loading then
callback('', nil)
return
end
load_forward_chunk_async(chunk_size, callback)
end
-- Forward declaration for ensure_content_loaded_async (used in read_file_streaming_async callback)
local ensure_content_loaded_async
local function read_file_streaming_async(file_path, callback)
local generation = M.state.preview_generation
init_dynamic_loading_async(file_path, function(success, error_msg)
if M.state.preview_generation ~= generation then return end
if not success then
callback(nil, error_msg)
return
end
-- Calculate initial chunk size based on location information
local initial_chunk_size = M.config.chunk_size
if M.state.location then
local target_line = location_utils.get_target_line(M.state.location)
if target_line then
-- Estimate bytes needed: assume ~100 bytes per line average
-- Add some buffer (50%) to account for variation in line lengths
local estimated_bytes = target_line * 100 * 1.5
-- Cap at reasonable maximum to avoid memory issues
local max_initial_chunk = M.config.max_size or (10 * 1024 * 1024) -- 10MB default
initial_chunk_size = math.min(estimated_bytes, max_initial_chunk)
-- Ensure we don't go below the standard chunk size
initial_chunk_size = math.max(initial_chunk_size, M.config.chunk_size)
end
end
load_next_chunk_async(initial_chunk_size, function(data, err)
if M.state.preview_generation ~= generation then return end
if data and data ~= '' then
local lines = split_chunk_with_remainder(data)
M.state.loaded_lines = #lines
M.state.content_height = #lines
-- If we have a location and didn't load enough lines, try to load more
local loading_more = false
if M.state.location then
local target_line = location_utils.get_target_line(M.state.location)
if target_line and #lines < target_line and M.state.has_more_content then
loading_more = true
vim.schedule(function()
if M.state.preview_generation == generation then ensure_content_loaded_async(target_line) end
end)
end
end
callback(lines, err, loading_more)
else
callback(nil, err)
end
end)
end)
end
ensure_content_loaded_async = function(target_line)
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
if not M.state.has_more_content or M.state.is_loading then return end
-- Guard against missing file handle: without it load_next_chunk_async returns
-- synchronously with empty data, which triggers apply_location_highlighting
-- -> ensure_content_loaded_async again, causing infinite recursion (stack overflow).
if not M.state.file_operation then
M.state.has_more_content = false
return
end
local current_buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
local buffer_needed = target_line + 50
if current_buffer_lines >= buffer_needed then return end
local generation = M.state.preview_generation
-- Use a larger chunk to reach the target faster instead of many small 8KB reads
local lines_needed = buffer_needed - current_buffer_lines
local estimated_bytes = math.max(M.config.chunk_size, lines_needed * 120)
load_next_chunk_async(estimated_bytes, function(data, err)
-- Stale callback: preview moved on to a different file
if M.state.preview_generation ~= generation then return end
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
if err then return end
if data and data ~= '' then
local chunk_lines = split_chunk_with_remainder(data)
if #chunk_lines > 0 then append_buffer_lines(M.state.bufnr, chunk_lines) end
M.state.content_height = vim.api.nvim_buf_line_count(M.state.bufnr)
M.state.loaded_lines = M.state.content_height
-- If we still haven't loaded enough, schedule another chunk
if M.state.loaded_lines < buffer_needed and M.state.has_more_content then
vim.schedule(function()
if M.state.preview_generation == generation then ensure_content_loaded_async(target_line) end
end)
else
-- Enough content loaded — re-apply location highlighting so the
-- preview scrolls to the correct line now that it exists in the buffer
M.apply_location_highlighting(M.state.bufnr)
end
else
-- EOF with no additional data — mark loading as finished to prevent
-- apply_location_highlighting -> ensure_content_loaded_async recursion,
-- then apply highlighting with whatever content we have.
M.state.has_more_content = false
M.apply_location_highlighting(M.state.bufnr)
end
end)
end
local function link_buffer_content(source_bufnr, target_bufnr)
local lines = vim.api.nvim_buf_get_lines(source_bufnr, 0, -1, false)
set_buffer_lines(target_bufnr, lines)
local source_ft = vim.api.nvim_get_option_value('filetype', { buf = source_bufnr })
if source_ft ~= '' then
local ok = set_preview_filetype(target_bufnr, source_ft)
if not ok then return false end
end
M.state.has_more_content = false
M.state.total_file_lines = #lines
M.state.loaded_lines = #lines
M.state.content_height = #lines
return true
end
M.config = nil
M.state = {
bufnr = nil,
winid = nil,
current_file = nil,
scroll_offset = 0,
content_height = 0,
loaded_lines = 0,
total_file_lines = nil,
loading_chunk_size = 1000,
is_loading = false,
has_more_content = true,
file_handle = nil, ---@type uv.uv_fs_t|nil
file_operation = nil, -- Ongoing file operation: {fd?: any, file_path?: string, position?: number}
location = nil, -- Current location data for highlighting
location_namespace = nil, -- Namespace for location highlighting
preview_generation = 0, -- Monotonically increasing token to detect stale async callbacks
is_binary_preview = false, -- Whether the current preview is a hex dump
hex_byte_offset = 0, -- Next byte offset for hex dump paging
}
--- Setup preview configuration
--- @param config table Configuration options
function M.setup(config)
M.config = config or {}
-- Create namespace for location highlighting
if not M.state.location_namespace then
M.state.location_namespace = vim.api.nvim_create_namespace('fff_preview_location')
end
end
--- Check if file is too big for initial preview (inspired by snacks.nvim)
--- @param file_path string Path to the file
--- @return boolean True if file is too big for initial preview
function M.is_big_file(file_path)
-- Only check file size for early detection - no line limits with dynamic loading
local stat = vim.uv.fs_stat(file_path)
if stat and stat.size > M.config.max_size then return true end
return false
end
--- Get file information
--- @param file_path string Path to the file
--- @return table | nil File information
function M.get_file_info(file_path)
local stat = vim.uv.fs_stat(file_path)
if not stat then return nil end
local info = {
name = vim.fn.fnamemodify(file_path, ':t'),
path = file_path,
size = stat.size,
modified = stat.mtime.sec,
accessed = stat.atime.sec,
type = stat.type,
}
info.extension = vim.fn.fnamemodify(file_path, ':e'):lower()
info.filetype = utils.detect_filetype(file_path) or 'text'
info.size_formatted = utils.format_file_size(info.size)
info.modified_formatted = os.date('%Y-%m-%d %H:%M:%S', info.modified)
info.accessed_formatted = os.date('%Y-%m-%d %H:%M:%S', info.accessed)
return info
end
--- Preview a regular file
--- @param file_path string Path to the file
--- @param bufnr number Buffer number for preview
--- @return boolean Success status
function M.preview_file(file_path, bufnr)
-- Early size detection to prevent memory issues
if M.is_big_file(file_path) then
local info = M.get_file_info(file_path)
local lines = {
'File too large for preview',
string.format(
'Size: %s (max: %s)',
info and info.size_formatted or 'Unknown',
string.format('%.1fMB', M.config.max_size / 1024 / 1024)
),
'',
'Use a text editor to view this file.',
}
set_buffer_lines(bufnr, lines)
return true
end
local info = M.get_file_info(file_path)
if not info then return false end
-- if the buffer is already opened for this file we reuse the buffer directly
local existing_bufnr = find_existing_buffer(file_path)
if existing_bufnr then
local success = link_buffer_content(existing_bufnr, bufnr)
if success then
local file_config = M.get_file_config(file_path)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
vim.api.nvim_set_option_value('wrap', file_config.wrap_lines or M.config.wrap_lines, { win = M.state.winid })
end
M.state.scroll_offset = 0
-- Apply location highlighting if available (delayed to ensure buffer is ready)
local gen = M.state.preview_generation
vim.schedule(function()
if M.state.preview_generation == gen then M.apply_location_highlighting(bufnr) end
end)
return true
end
end
M.state.current_file = file_path
M.state.bufnr = bufnr
local generation = M.state.preview_generation
read_file_streaming_async(file_path, function(content, err, loading_more)
if M.state.preview_generation ~= generation then
-- Preview moved on to a different file, discard
cleanup_file_operation()
return
end
if err or not content then
if M.state.current_file == file_path then
set_buffer_lines(bufnr, { 'Failed to load file: ' .. (err or 'unknown error') })
end
return
end
if M.state.current_file == file_path then
-- Guard against buffer being destroyed while async read was in-flight
if not vim.api.nvim_buf_is_valid(bufnr) then return end
M.clear_preview_visual_state(bufnr)
set_buffer_lines(bufnr, content)
local file_config = M.get_file_config(file_path)
local ok = set_preview_filetype(bufnr, info.filetype)
if not ok then
show_preview_unavailable(bufnr)
return
end
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
vim.api.nvim_set_option_value('wrap', file_config.wrap_lines or M.config.wrap_lines, { win = M.state.winid })
end
M.state.content_height = #content
M.state.scroll_offset = 0
-- Apply location highlighting if available (delayed to ensure buffer is ready).
-- Skip when more content is being loaded asynchronously to reach the target line —
-- ensure_content_loaded_async will re-apply highlighting once the target is in the buffer.
if not loading_more then
vim.schedule(function()
if M.state.preview_generation == generation then M.apply_location_highlighting(bufnr) end
end)
end
end
end)
return true
end
-- Hex preview highlight support: dynamically create hl groups from "#rrggbb"
local hex_ns = nil
local hex_hl_cache = {}
local function ensure_hex_ns()
if not hex_ns then hex_ns = vim.api.nvim_create_namespace('fff_hex_preview') end
return hex_ns
end
local function get_hex_hl_group(hex_color)
local cached = hex_hl_cache[hex_color]
if cached then return cached end
local group = 'FffHex_' .. hex_color:sub(2)
vim.api.nvim_set_hl(0, group, { fg = hex_color })
hex_hl_cache[hex_color] = group
return group
end
--- Apply hex highlight spans to a buffer
--- @param bufnr number Buffer number
--- @param highlights table Array of {line_0idx, col_start, col_end, "#rrggbb"}
--- @param line_offset number Lines to add to each highlight line index (for header)
local function apply_hex_highlights(bufnr, highlights, line_offset)
if not highlights or not vim.api.nvim_buf_is_valid(bufnr) then return end
local ns = ensure_hex_ns()
for _, hl in ipairs(highlights) do
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, hl[1] + line_offset, hl[2], {
end_col = hl[3],
hl_group = get_hex_hl_group(hl[4]),
})
end
end
--- Load a page of hex dump content from the Rust backend
--- @param file_path string Path to the binary file
--- @param byte_offset number Byte offset to start reading from
--- @return table|nil Result with lines, highlights, has_more, next_offset
local function load_hex_page(file_path, byte_offset)
local ok, result = pcall(rust.hex_dump, file_path, byte_offset, 4096)
if ok and result then return result end
return nil
end
--- Load more hex content when scrolling near the end of the buffer
local function load_more_hex_content()
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
if not M.state.has_more_content or not M.state.current_file then return end
local current_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
local result = load_hex_page(M.state.current_file, M.state.hex_byte_offset)
if result and result.lines and #result.lines > 0 then
append_buffer_lines(M.state.bufnr, result.lines)
M.state.hex_byte_offset = result.next_offset
M.state.has_more_content = result.has_more
M.state.content_height = vim.api.nvim_buf_line_count(M.state.bufnr)
M.state.loaded_lines = M.state.content_height
apply_hex_highlights(M.state.bufnr, result.highlights, current_lines)
else
M.state.has_more_content = false
end
end
--- Preview a binary file using hexyl-powered hex dump with paging
--- @param file_path string Path to the file
--- @param bufnr number Buffer number for preview
--- @return boolean Success status
function M.preview_binary_file(file_path, bufnr)
local info = M.get_file_info(file_path)
local lines = {}
M.state.is_binary_preview = true
M.state.hex_byte_offset = 0
-- Build header synchronously (file -b is fast, typically <10ms)
if vim.fn.executable('file') == 1 then
local output = vim.fn.system({ 'file', '-b', file_path })
if vim.v.shell_error == 0 and output then
local file_type = output:gsub('\n', '')
table.insert(lines, 'Binary file: ' .. file_type)
if info and info.size_formatted then table.insert(lines, 'Size: ' .. info.size_formatted) end
table.insert(lines, '')
end
end
local hex_result = load_hex_page(file_path, 0)
if hex_result and hex_result.lines then
for _, hex_line in ipairs(hex_result.lines) do
table.insert(lines, hex_line)
end
M.state.hex_byte_offset = hex_result.next_offset
M.state.has_more_content = hex_result.has_more
end
set_buffer_lines(bufnr, lines)
local ok = set_preview_filetype(bufnr, 'text')
if not ok then
show_preview_unavailable(bufnr)
return false
end
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
M.state.content_height = #lines
M.state.loaded_lines = #lines
if hex_result and hex_result.highlights then
local header_lines = #lines - (hex_result.lines and #hex_result.lines or 0)
apply_hex_highlights(bufnr, hex_result.highlights, header_lines)
end
return true
end
--- Get file-specific configuration
--- @param file_path string Path to the file
--- @return table Configuration for the file
function M.get_file_config(file_path)
if not M.config or not M.config.filetypes then return {} end
local filetype = utils.detect_filetype(file_path) or 'text'
return M.config.filetypes[filetype] or {}
end
--- @param file_path string Path to the file or directory
--- @param bufnr number Buffer number for preview
--- @param location table|nil Optional location data for highlighting
--- @param is_binary boolean|nil Whether the file is binary (from Rust indexer)
--- @return boolean if the preview was successful
function M.preview(file_path, bufnr, location, is_binary)
if not file_path or file_path == '' then return false end
-- Bump generation to invalidate any in-flight async callbacks from previous previews
M.state.preview_generation = M.state.preview_generation + 1
if M.state.file_handle then
---@diagnostic disable-next-line: undefined-field
M.state.file_handle:close()
M.state.file_handle = nil
end
M.state.loaded_lines = 0
M.state.total_file_lines = nil
M.state.has_more_content = true
M.state.is_loading = false
M.state.hex_byte_offset = 0
M.state.is_binary_preview = false
M.state.current_file = file_path
M.state.bufnr = bufnr
M.state.location = location
if image.is_image(file_path) then
M.clear_buffer(bufnr)
if not M.state.winid or not vim.api.nvim_win_is_valid(M.state.winid) then return false end
return image.display_image(file_path, bufnr)
elseif is_binary then
return M.preview_binary_file(file_path, bufnr)
else
return M.preview_file(file_path, bufnr)
end
end
function M.scroll(lines)
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
if not M.state.winid or not vim.api.nvim_win_is_valid(M.state.winid) then return end
local win_height = vim.api.nvim_win_get_height(M.state.winid)
local current_buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
local current_offset = M.state.scroll_offset or 0
local new_offset = current_offset + lines
-- If scrolling down and approaching end of loaded content, try to load more
if lines > 0 and not M.state.is_loading then
local target_line = new_offset + win_height
local buffer_needed = target_line + 20
if current_buffer_lines < buffer_needed and M.state.has_more_content then
if M.state.is_binary_preview then
load_more_hex_content()
-- Re-read line count after loading more
current_buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
else
ensure_content_loaded_async(target_line)
end
end
end
local content_height = current_buffer_lines
local half_screen = math.floor(win_height / 2)
local max_scroll = math.max(0, content_height + half_screen - win_height)
new_offset = math.max(0, math.min(max_scroll, new_offset))
if new_offset ~= current_offset then
M.state.scroll_offset = new_offset
M.state.content_height = content_height
local target_line = math.min(content_height, math.max(1, new_offset + 1))
vim.api.nvim_win_call(M.state.winid, function()
vim.api.nvim_win_set_cursor(M.state.winid, { target_line, 0 })
vim.cmd('normal! zt')
end)
end
end
--- Set preview window
--- @param winid number Window ID for the preview
function M.set_preview_window(winid) M.state.winid = winid end
local function resolve_absolute_path(rel)
if not rel or rel == '' then return nil end
if rel:sub(1, 1) == '/' then return rel end
local base = require('fff.conf').get().base_path
if base and base ~= '' then return (base:sub(-1) == '/') and (base .. rel) or (base .. '/' .. rel) end
return vim.fn.fnamemodify(rel, ':p')
end
local function build_file_info_data(file, file_metadata, file_index)
local conf = require('fff.conf').get()
local is_grep = file.line_number ~= nil
local sections = (conf.debug and conf.debug.show_file_info)
or { file_info = true, score_breakdown = true, timings = true, full_path = true }
-- Grep matches don't have a per-file fuzzy score, so the breakdown would
-- be all "N/A". Skip it but keep the rest of the panel identical.
if is_grep then sections = vim.tbl_extend('force', sections, { score_breakdown = false }) end
local abs_path = resolve_absolute_path(file.relative_path)
local times_opened = 0
if abs_path then
local ok, n = pcall(rust.get_file_access_count, abs_path)
if ok and type(n) == 'number' then times_opened = n end
end
local file_view = {
relative_path = file.relative_path,
absolute_path = abs_path or file.absolute_path or file_metadata.path,
size_formatted = file_metadata.size_formatted,
filetype = file_metadata.filetype or 'text',
git_status = file.git_status or 'clean',
access_frecency_score = file.access_frecency_score or 0,
modification_frecency_score = file.modification_frecency_score or 0,
times_opened = times_opened,
modified_formatted = file_metadata.modified_formatted,
accessed_formatted = file_metadata.accessed_formatted,
}
local score = nil
if not is_grep and file_index then score = file_picker.get_file_score(file_index) end
return {
file = file_view,
score = score,
sections = sections,
hls = conf.hl,
}
end
local function apply_file_info_extmarks(bufnr, extmarks)
vim.api.nvim_buf_clear_namespace(bufnr, M.file_info_ns, 0, -1)
for _, m in ipairs(extmarks) do
local opts = {}
if m.hl_group then
opts.hl_group = m.hl_group
if m.end_col then opts.end_col = m.end_col end
end
if m.virt_text then
opts.virt_text = m.virt_text
opts.virt_text_pos = m.virt_text_pos or 'overlay'
end
pcall(vim.api.nvim_buf_set_extmark, bufnr, M.file_info_ns, m.row, m.col or 0, opts)
end
end
local function resize_file_info(file_info_win, target_h, preview_win)
if not (file_info_win and vim.api.nvim_win_is_valid(file_info_win)) then return end
local current_h = vim.api.nvim_win_get_height(file_info_win)
local delta = target_h - current_h
if delta == 0 then return end
pcall(vim.api.nvim_win_set_config, file_info_win, { height = target_h })
if preview_win and vim.api.nvim_win_is_valid(preview_win) then
local cfg = vim.api.nvim_win_get_config(preview_win)
if cfg.row and cfg.height then
local new_row = (type(cfg.row) == 'table' and cfg.row[false] or cfg.row) + delta
pcall(vim.api.nvim_win_set_config, preview_win, {
relative = cfg.relative or 'editor',
row = new_row,
col = cfg.col,
width = cfg.width,
height = math.max(3, cfg.height - delta),
})
end
end
end
--- Update file info buffer
--- @param file table
--- @param bufnr number
--- @param file_index number|nil
--- @param preview_win number|nil Preview window — shifted to keep flush with file_info
--- @return boolean
function M.update_file_info_buffer(file, bufnr, file_index, preview_win)
if not file then
set_buffer_lines(bufnr, { 'No file selected' })
return false
end
local info = M.get_file_info(file.relative_path)
if not info then
set_buffer_lines(bufnr, { 'File info unavailable' })
return false
end
local width = 80
local target_win
local wins = vim.fn.win_findbuf(bufnr)
for _, win in ipairs(wins) do
if vim.api.nvim_win_is_valid(win) then
width = vim.api.nvim_win_get_width(win)
target_win = win
break
end
end
local data = build_file_info_data(file, info, file_index)
data.width = width
local result = file_info_renderer.build(data)
set_buffer_lines(bufnr, result.lines)
apply_file_info_extmarks(bufnr, result.extmarks)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
for _, win in ipairs(wins) do
if vim.api.nvim_win_is_valid(win) then vim.api.nvim_set_option_value('wrap', false, { win = win }) end
end
if result.height > 0 then resize_file_info(target_win, result.height, preview_win) end
return true
end
function M.clear_preview_visual_state(bufnr)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
-- Only clear visual state, don't affect buffer functionality
-- Clear namespaces and extmarks for this buffer only
vim.api.nvim_buf_clear_namespace(bufnr, -1, 0, -1)
-- Clear location highlights
if M.state.location_namespace then location_utils.clear_location_highlights(bufnr, M.state.location_namespace) end
local wins = vim.fn.win_findbuf(bufnr)
for _, win in ipairs(wins) do
if vim.api.nvim_win_is_valid(win) then
-- Reset folds
pcall(vim.api.nvim_win_call, win, function()
if vim.fn.has('folding') == 1 then
vim.cmd('normal! zE') -- eliminate all folds
vim.opt_local.foldenable = false -- disable folding
end
end)
end
end
image.clear_buffer_images(bufnr)
end
function M.clear_buffer(bufnr)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
cleanup_file_operation()
M.clear_preview_visual_state(bufnr)
pcall(vim.treesitter.stop, bufnr)
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
set_preview_filetype(bufnr, '')
vim.api.nvim_set_option_value('syntax', '', { buf = bufnr })
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
set_buffer_lines(bufnr, {})
end
function M.clear()
-- Bump generation to invalidate any in-flight async callbacks
M.state.preview_generation = M.state.preview_generation + 1
cleanup_file_operation()
M.state.loaded_lines = 0
M.state.total_file_lines = nil
M.state.has_more_content = true
M.state.is_loading = false
if M.state.bufnr and vim.api.nvim_buf_is_valid(M.state.bufnr) then M.clear_buffer(M.state.bufnr) end
M.state.current_file = nil
M.state.scroll_offset = 0
M.state.content_height = 0
M.state.location = nil
M.state.is_binary_preview = false
M.state.hex_byte_offset = 0
end
--- Apply location highlighting to the preview buffer
--- @param bufnr number Buffer number
function M.apply_location_highlighting(bufnr)
-- Ensure namespace is created
if not M.state.location_namespace then
M.state.location_namespace = vim.api.nvim_create_namespace('fff_preview_location')
end
-- Always clear previous location highlights first
if vim.api.nvim_buf_is_valid(bufnr) then
location_utils.clear_location_highlights(bufnr, M.state.location_namespace)
end
if not M.state.location then return end
location_utils.highlight_location(bufnr, M.state.location, M.state.location_namespace)
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
local target_line = location_utils.get_target_line(M.state.location)
if target_line then
local buffer_lines = vim.api.nvim_buf_line_count(bufnr)
if target_line > buffer_lines and M.state.has_more_content then
-- Target line is beyond loaded content — load more first.
-- ensure_content_loaded_async will re-apply highlighting when done.
ensure_content_loaded_async(target_line)
return
end
M.scroll_to_line(target_line)
end
end
end
--- Scroll preview to a specific line
--- @param line number Target line number (1-indexed)
function M.scroll_to_line(line)
if not M.state.winid or not vim.api.nvim_win_is_valid(M.state.winid) then return end
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
local win_height = vim.api.nvim_win_get_height(M.state.winid)
local buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
local target_line = math.max(1, math.min(line, buffer_lines))
local half_screen = math.floor(win_height / 2)
local new_offset = math.max(0, target_line - half_screen)
M.state.scroll_offset = new_offset
pcall(vim.api.nvim_win_call, M.state.winid, function()
vim.api.nvim_win_set_cursor(M.state.winid, { target_line, 0 })
vim.cmd('normal! zt')
end)
end
return M
+58
View File
@@ -0,0 +1,58 @@
---@class fff.fuzzy
local M = {}
-- Try to load the Rust module
local ok, rust_module = pcall(require, 'fff.rust')
if not ok then error('Failed to load fff.rust module: ' .. rust_module) end
-- export all functions from the Rust module
M.init_db = rust_module.init_db
M.destroy_frecency_db = rust_module.destroy_frecency_db
M.access = rust_module.access
M.set_provider_items = rust_module.set_provider_items
M.fuzzy = rust_module.fuzzy
M.fuzzy_matched_indices = rust_module.fuzzy_matched_indices
M.get_keyword_range = rust_module.get_keyword_range
M.guess_edit_range = rust_module.guess_edit_range
M.get_words = rust_module.get_words
M.init_file_picker = rust_module.init_file_picker
M.restart_index_in_path = rust_module.restart_index_in_path
M.scan_files = rust_module.scan_files
M.get_cached_files = rust_module.get_cached_files
M.fuzzy_search_files = rust_module.fuzzy_search_files
M.fuzzy_search_directories = rust_module.fuzzy_search_directories
M.fuzzy_search_mixed = rust_module.fuzzy_search_mixed
M.track_access = rust_module.track_access
M.get_file_access_count = rust_module.get_file_access_count
M.add_file = rust_module.add_file
M.remove_file = rust_module.remove_file
M.cancel_scan = rust_module.cancel_scan
M.get_scan_progress = rust_module.get_scan_progress
M.is_scanning = rust_module.is_scanning
M.refresh_git_status = rust_module.refresh_git_status
M.update_single_file_frecency = rust_module.update_single_file_frecency
M.stop_background_monitor = rust_module.stop_background_monitor
M.cleanup_file_picker = rust_module.cleanup_file_picker
M.init_tracing = rust_module.init_tracing
M.wait_for_initial_scan = rust_module.wait_for_initial_scan
-- Query tracking functions
M.init_query_db = rust_module.init_query_db
M.destroy_query_db = rust_module.destroy_query_db
M.track_query_completion = rust_module.track_query_completion
M.get_historical_query = rust_module.get_historical_query
M.track_grep_query = rust_module.track_grep_query
M.get_historical_grep_query = rust_module.get_historical_grep_query
-- Git functions
M.get_git_root = rust_module.get_git_root
-- Grep functions
M.live_grep = rust_module.live_grep
M.parse_grep_query = rust_module.parse_grep_query
-- Utility functions
M.health_check = rust_module.health_check
M.shorten_path = rust_module.shorten_path
return M
+362
View File
@@ -0,0 +1,362 @@
local utils = require('fff.utils')
local M = {}
local function fetch_rust_checkhealth(rust_module, test_path)
if not rust_module.health_check then
return nil, 'health_check function not available in rust module (binary may be outdated)'
end
local ok, result = pcall(rust_module.health_check, test_path)
if not ok then return nil, 'Failed to call health_check: ' .. tostring(result) end
return result, nil
end
-- Report health for a rust-side LMDB section (frecency / query_tracker).
-- opts.populate(section, db_info) copies DB-specific counters into health_section.
-- opts.format_healthy_msg(db_info) returns the message for the healthy path;
-- the unhealthy path is handled uniformly here as a critical error.
local function report_db_health(rust_section, health_section, messages, label, opts)
if not rust_section then return end
health_section.initialized = rust_section.initialized
health_section.error = rust_section.error
if not rust_section.initialized then
table.insert(messages, {
level = 'info',
msg = label .. ' not initialized (will initialize on first use)',
})
return
end
local db_info = rust_section.db_healthcheck
if not db_info then
if rust_section.db_healthcheck_error then
table.insert(messages, {
level = 'warn',
msg = label .. ' initialized but health check failed: ' .. rust_section.db_healthcheck_error,
})
else
table.insert(messages, { level = 'ok', msg = label .. ' initialized' })
end
return
end
health_section.db_path = db_info.path
health_section.disk_size = db_info.disk_size
health_section.healthy = db_info.healthy
if opts.populate then opts.populate(health_section, db_info) end
if db_info.healthy == false then
table.insert(messages, {
level = 'error',
msg = string.format(
'%s UNRESPONSIVE — CRITICAL ERROR (see logs; try removing %s/lock.mdb to unblock)',
label,
db_info.path or 'unknown'
),
})
return
end
table.insert(messages, {
level = 'ok',
msg = opts.format_healthy_msg(db_info),
})
end
--- Check snacks.nvim image preview availability
--- @return table image_preview_info
local function check_image_preview()
local ok, image = pcall(require, 'fff.file_picker.image')
if not ok then
return {
available = false,
snacks_available = false,
snacks_image_available = false,
terminal_supported = false,
error = 'failed to load image module',
}
end
return image.get_preview_status()
end
--- Check icon provider availability
--- @return table icon_provider_info
local function check_icon_provider()
local ok, icons = pcall(require, 'fff.file_picker.icons')
if not ok then return {
available = false,
name = nil,
supports_directories = false,
} end
return icons.get_provider_info()
end
--- Run the health check and return structured results
--- @param opts? { test_path?: string } Options for health check
--- @return table health_result
function M.run(opts)
opts = opts or {}
local health = {
ok = true,
binary = {
available = false,
path = nil,
error = nil,
},
rust = {
version = nil,
git = {
available = false,
repository_found = false,
workdir = nil,
libgit2_version = nil,
error = nil,
},
file_picker = {
initialized = false,
base_path = nil,
is_scanning = false,
indexed_files = 0,
error = nil,
},
frecency = {
initialized = false,
db_path = nil,
disk_size = nil,
entries = nil,
healthy = nil,
error = nil,
},
query_tracker = {
initialized = false,
db_path = nil,
disk_size = nil,
query_file_entries = nil,
query_history_entries = nil,
healthy = nil,
error = nil,
},
},
image_preview = {
available = false,
snacks_available = false,
snacks_image_available = false,
terminal_supported = false,
error = nil,
},
icon_provider = {
available = false,
name = nil,
supports_directories = false,
},
messages = {},
}
-- Check binary availability
local download = require('fff.download')
health.binary.path = download.get_binary_path()
local binary_ok, rust_module = pcall(require, 'fff.rust')
if not binary_ok then
health.ok = false
health.binary.available = false
health.binary.error = tostring(rust_module)
table.insert(health.messages, {
level = 'error',
msg = 'Binary not available: ' .. tostring(rust_module),
})
return health
end
health.binary.available = true
table.insert(health.messages, {
level = 'ok',
msg = 'Binary loaded successfully from: ' .. health.binary.path,
})
local rust_health, rust_err = fetch_rust_checkhealth(rust_module, opts.test_path)
if rust_health then
health.rust.version = rust_health.version
table.insert(health.messages, {
level = 'ok',
msg = 'fff.nvim version: ' .. (rust_health.version or 'unknown'),
})
if rust_health.git then
health.rust.git.available = rust_health.git.available
health.rust.git.repository_found = rust_health.git.repository_found
health.rust.git.workdir = rust_health.git.workdir
health.rust.git.libgit2_version = rust_health.git.libgit2_version
health.rust.git.error = rust_health.git.error
if rust_health.git.available then
table.insert(health.messages, {
level = 'ok',
msg = 'libgit2 available (version: ' .. (rust_health.git.libgit2_version or 'unknown') .. ')',
})
if rust_health.git.repository_found then
table.insert(health.messages, {
level = 'ok',
msg = 'Git repository found: ' .. (rust_health.git.workdir or 'unknown'),
})
else
table.insert(health.messages, {
level = 'info',
msg = 'No git repository found in current directory'
.. (rust_health.git.error and (': ' .. rust_health.git.error) or ''),
})
end
else
table.insert(health.messages, {
level = 'warn',
msg = 'libgit2 not available',
})
end
end
if rust_health.file_picker then
health.rust.file_picker.initialized = rust_health.file_picker.initialized
health.rust.file_picker.base_path = rust_health.file_picker.base_path
health.rust.file_picker.is_scanning = rust_health.file_picker.is_scanning
health.rust.file_picker.indexed_files = rust_health.file_picker.indexed_files
health.rust.file_picker.error = rust_health.file_picker.error
if rust_health.file_picker.initialized then
local status = rust_health.file_picker.is_scanning and 'scanning' or 'ready'
table.insert(health.messages, {
level = 'ok',
msg = string.format(
'File picker initialized (%s, %d files indexed, base: %s)',
status,
rust_health.file_picker.indexed_files or 0,
rust_health.file_picker.base_path or 'unknown'
),
})
else
table.insert(health.messages, {
level = 'info',
msg = 'File picker not initialized (will initialize on first use)',
})
end
end
report_db_health(rust_health.frecency, health.rust.frecency, health.messages, 'Frecency database', {
populate = function(section, db_info) section.entries = db_info.absolute_frecency_entries end,
format_healthy_msg = function(db_info)
return string.format(
'Frecency database operational (%d entries, %s, path: %s)',
db_info.absolute_frecency_entries or 0,
utils.format_file_size(db_info.disk_size or 0),
db_info.path or 'unknown'
)
end,
})
report_db_health(rust_health.query_tracker, health.rust.query_tracker, health.messages, 'Query cache database', {
populate = function(section, db_info)
section.query_file_entries = db_info.query_file_entries
section.query_history_entries = db_info.query_history_entries
end,
format_healthy_msg = function(db_info)
return string.format(
'Query cache database operational (%d query-file mappings, %d history entries, %s, path: %s)',
db_info.query_file_entries or 0,
db_info.query_history_entries or 0,
utils.format_file_size(db_info.disk_size or 0),
db_info.path or 'unknown'
)
end,
})
else
health.ok = false
table.insert(health.messages, {
level = 'error',
msg = rust_err or 'Unknown error getting rust health data',
})
return health
end
local image_info = check_image_preview()
health.image_preview.snacks_available = image_info.snacks_available
health.image_preview.snacks_image_available = image_info.snacks_image_available
health.image_preview.terminal_supported = image_info.terminal_supported
health.image_preview.error = image_info.error
health.image_preview.available = image_info.available
if image_info.available then
table.insert(health.messages, {
level = 'ok',
msg = 'Image preview available via snacks.nvim',
})
elseif image_info.snacks_available and image_info.snacks_image_available then
table.insert(health.messages, {
level = 'info',
msg = 'Image preview not available: ' .. (image_info.error or 'terminal does not support images'),
})
elseif image_info.snacks_available then
table.insert(health.messages, {
level = 'info',
msg = 'Image preview not available: snacks.image module not found',
})
else
table.insert(health.messages, {
level = 'info',
msg = 'Image preview not available: snacks.nvim not installed',
})
end
local icon_info = check_icon_provider()
health.icon_provider.available = icon_info.available
health.icon_provider.name = icon_info.name
health.icon_provider.supports_directories = icon_info.supports_directories
if icon_info.available then
table.insert(health.messages, {
level = 'ok',
msg = 'Filetype icons available via ' .. icon_info.name,
})
else
table.insert(health.messages, {
level = 'info',
msg = 'Filetype icons not available (install nvim-web-devicons or mini.icons)',
})
end
return health
end
function M.check()
vim.health.start('fff.nvim')
local result = M.run()
for _, msg in ipairs(result.messages) do
if msg.level == 'ok' then
vim.health.ok(msg.msg)
elseif msg.level == 'warn' then
vim.health.warn(msg.msg)
elseif msg.level == 'error' then
vim.health.error(msg.msg)
elseif msg.level == 'info' then
vim.health.info(msg.msg)
end
end
if not result.binary.available then
vim.health.info('To install the binary, run:')
vim.health.info(' :lua require("fff.download").download_or_build_binary()')
vim.health.info('Or build from source with:')
vim.health.info(' cargo build --release')
end
end
return M
+304
View File
@@ -0,0 +1,304 @@
local M = {}
-- Git sign border characters per status
M.git_border_chars = {
untracked = '', -- Dotted vertical line
ignored = '', -- Dotted vertical line
unknown = '',
modified = '', -- Vertical line
deleted = '', -- Bottom horizontal line
renamed = '', -- Vertical line
staged_new = '', -- Vertical line
staged_modified = '', -- Vertical line
staged_deleted = '', -- Bottom horizontal line
clean = '',
clear = '',
}
local git_text_highlights_cache = nil
local git_border_highlights_cache = nil
local git_border_highlights_selected_cache = nil
-- Cache for cursor-blended git border highlights, keyed by git_status.
-- Cleared on setup() since it depends on user's Cursor/Visual colours.
local git_cursor_border_cache = {}
local function ensure_git_cache()
if git_text_highlights_cache then return end
local config = require('fff.conf').get()
git_text_highlights_cache = {
untracked = config.hl.git_untracked,
modified = config.hl.git_modified,
deleted = config.hl.git_deleted,
renamed = config.hl.git_renamed,
staged_new = config.hl.git_staged,
staged_modified = config.hl.git_staged,
staged_deleted = config.hl.git_staged,
ignored = config.hl.git_ignored,
clean = '',
clear = '',
unknown = config.hl.git_untracked,
}
git_border_highlights_cache = {
untracked = config.hl.git_sign_untracked,
modified = config.hl.git_sign_modified,
deleted = config.hl.git_sign_deleted,
renamed = config.hl.git_sign_renamed,
staged_new = config.hl.git_sign_staged,
staged_modified = config.hl.git_sign_staged,
staged_deleted = config.hl.git_sign_staged,
ignored = config.hl.git_sign_ignored,
clean = '',
clear = '',
unknown = config.hl.git_sign_untracked,
}
git_border_highlights_selected_cache = {
untracked = config.hl.git_sign_untracked_selected,
modified = config.hl.git_sign_modified_selected,
deleted = config.hl.git_sign_deleted_selected,
renamed = config.hl.git_sign_renamed_selected,
staged_new = config.hl.git_sign_staged_selected,
staged_modified = config.hl.git_sign_staged_selected,
staged_deleted = config.hl.git_sign_staged_selected,
ignored = config.hl.git_sign_ignored_selected,
clean = '',
clear = '',
unknown = config.hl.git_sign_untracked_selected,
}
end
--- Get git sign border character for a status
--- @param git_status string Git status
--- @return string Border character
function M.get_git_border_char(git_status) return M.git_border_chars[git_status] or '' end
--- Get highlight group for git status text (filename)
--- @param git_status string Git status
--- @return string Highlight group name
function M.get_git_text_highlight(git_status)
ensure_git_cache()
return git_text_highlights_cache and git_text_highlights_cache[git_status] or ''
end
--- Get sign-column border highlight group for git status
--- @param git_status string Git status
--- @return string Highlight group name
function M.get_git_border_highlight(git_status)
ensure_git_cache()
return git_border_highlights_cache and git_border_highlights_cache[git_status] or ''
end
--- Get sign-column border highlight group for git status when row is selected
--- @param git_status string Git status
--- @return string Highlight group name
function M.get_git_border_highlight_selected(git_status)
ensure_git_cache()
return git_border_highlights_selected_cache and git_border_highlights_selected_cache[git_status] or ''
end
--- Whether a git status warrants a sign-column border at all
--- @param git_status string Git status
--- @return boolean
function M.should_show_git_border(git_status)
return git_status == 'untracked'
or git_status == 'modified'
or git_status == 'staged_new'
or git_status == 'staged_modified'
or git_status == 'deleted'
or git_status == 'staged_deleted'
or git_status == 'renamed'
end
--- Resolve the appropriate git sign highlight for a row.
--- When the row is under the cursor, blends the git border foreground onto
--- the cursor highlight's background so the sign reads as part of the cursor line.
--- @param git_status string Git status
--- @param is_cursor boolean Whether the row is the current cursor row
--- @param cursor_hl string Cursor highlight group name
--- @return string Highlight group name (may be a generated group)
function M.get_git_sign_highlight(git_status, is_cursor, cursor_hl)
if not is_cursor then return M.get_git_border_highlight(git_status) end
local base_hl = M.get_git_border_highlight_selected(git_status)
if not base_hl or base_hl == '' then return cursor_hl end
local cached = git_cursor_border_cache[git_status]
if cached then return cached end
local base_id = vim.fn.synIDtrans(vim.fn.hlID(base_hl))
local cursor_id = vim.fn.synIDtrans(vim.fn.hlID(cursor_hl))
local border_fg_gui = vim.fn.synIDattr(base_id, 'fg', 'gui')
local border_fg_cterm = vim.fn.synIDattr(base_id, 'fg', 'cterm')
local cursor_bg_gui = vim.fn.synIDattr(cursor_id, 'bg', 'gui')
local cursor_bg_cterm = vim.fn.synIDattr(cursor_id, 'bg', 'cterm')
local has_gui = border_fg_gui ~= '' and cursor_bg_gui ~= ''
local has_cterm = border_fg_cterm ~= '' and cursor_bg_cterm ~= ''
if not has_gui and not has_cterm then
git_cursor_border_cache[git_status] = base_hl
return base_hl
end
local blended_name = 'FFFGitBorderSelected_' .. git_status
local hl_opts = {}
if has_gui then
hl_opts.fg = border_fg_gui
hl_opts.bg = cursor_bg_gui
end
if has_cterm then
hl_opts.ctermfg = tonumber(border_fg_cterm)
hl_opts.ctermbg = tonumber(cursor_bg_cterm)
end
vim.api.nvim_set_hl(0, blended_name, hl_opts)
git_cursor_border_cache[git_status] = blended_name
return blended_name
end
function M.setup()
-- Reset caches so highlights pick up updated config / colorscheme values.
git_text_highlights_cache = nil
git_border_highlights_cache = nil
git_border_highlights_selected_cache = nil
git_cursor_border_cache = {}
vim.cmd([[
" Symbol highlights
highlight default FFFGitStaged guifg=#10B981 ctermfg=2
highlight default FFFGitModified guifg=#F59E0B ctermfg=3
highlight default FFFGitDeleted guifg=#EF4444 ctermfg=1
highlight default FFFGitRenamed guifg=#8B5CF6 ctermfg=5
highlight default FFFGitUntracked guifg=#10B981 ctermfg=2
highlight default FFFGitIgnored guifg=#4B5563 ctermfg=8
" Thin border highlights
highlight default FFFGitSignStaged guifg=#10B981 ctermfg=2
highlight default FFFGitSignModified guifg=#F59E0B ctermfg=3
highlight default FFFGitSignDeleted guifg=#EF4444 ctermfg=1
highlight default FFFGitSignRenamed guifg=#8B5CF6 ctermfg=5
highlight default FFFGitSignUntracked guifg=#10B981 ctermfg=2
highlight default FFFGitSignIgnored guifg=#4B5563 ctermfg=8
" Fallback to GitSigns highlights if they exist
highlight default link FFFGitSignStaged GitSignsAdd
highlight default link FFFGitSignModified GitSignsChange
highlight default link FFFGitSignDeleted GitSignsDelete
highlight default link FFFGitSignUntracked GitSignsAdd
" File info panel highlights — defaults link to good fallbacks; users can override.
" FFFFileInfoValue is set explicitly below (Normal's fg only) so it doesn't
" import Normal's bg into the float (float content uses NormalFloat).
highlight default link FFFFileInfoSection Title
highlight default link FFFFileInfoSeparator FloatBorder
highlight default link FFFFileInfoLabel Comment
highlight default link FFFFileInfoValueDim NonText
highlight default link FFFFileInfoSize Number
highlight default link FFFFileInfoType Type
highlight default link FFFFileInfoPath Directory
highlight default link FFFFileInfoScorePos DiagnosticOk
highlight default link FFFFileInfoScoreNeg DiagnosticError
" Bold for the total score and the match-type tag.
highlight default FFFFileInfoTotalScore gui=bold cterm=bold
highlight default FFFFileInfoMatchType gui=bold cterm=bold
]])
-- Resolve an attribute by walking the link chain.
local function resolve_hl_attr(name, attr)
local hl = vim.api.nvim_get_hl(0, { name = name, link = false })
if hl[attr] ~= nil then return hl[attr] end
local linked = vim.api.nvim_get_hl(0, { name = name })
if linked.link then return resolve_hl_attr(linked.link, attr) end
return nil
end
-- File info value text: copy Normal's fg only. Linking to Normal would
-- carry Normal.bg into the float (whose content uses NormalFloat), creating
-- visible patches behind plain values.
local normal_fg = resolve_hl_attr('Normal', 'fg')
if normal_fg then
vim.api.nvim_set_hl(0, 'FFFFileInfoValue', { fg = normal_fg, default = true })
else
vim.api.nvim_set_hl(0, 'FFFFileInfoValue', { link = 'NormalFloat', default = true })
end
-- Resolve link target's fg so we can combine bold + colour (link + gui=bold
-- can't be set in one nvim_set_hl call).
local function bold_with_fallback(name, fallback_groups)
for _, fb in ipairs(fallback_groups) do
local hl = vim.api.nvim_get_hl(0, { name = fb, link = false })
if hl and hl.fg then
vim.api.nvim_set_hl(0, name, { fg = hl.fg, bold = true, default = true })
return
end
end
end
bold_with_fallback('FFFFileInfoTotalScore', { 'Number', 'Constant', 'Identifier' })
bold_with_fallback('FFFFileInfoMatchType', { 'Special', 'Statement', 'Keyword' })
-- Highlights for git signs both for selected and normal states
local git_highlights = {
{ 'FFFGitSignStaged', 'FFFGitSignStagedSelected', '#10B981', 2 },
{ 'FFFGitSignModified', 'FFFGitSignModifiedSelected', '#F59E0B', 3 },
{ 'FFFGitSignDeleted', 'FFFGitSignDeletedSelected', '#EF4444', 1 },
{ 'FFFGitSignRenamed', 'FFFGitSignRenamedSelected', '#8B5CF6', 5 },
{ 'FFFGitSignUntracked', 'FFFGitSignUntrackedSelected', '#10B981', 2 },
{ 'FFFGitSignIgnored', 'FFFGitSignIgnoredSelected', '#4B5563', 8 },
}
local visual_bg_gui = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Visual')), 'bg', 'gui')
local visual_bg_cterm = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Visual')), 'bg', 'cterm')
for _, hl in ipairs(git_highlights) do
local _, selected_hl, gui_fg, cterm_fg = hl[1], hl[2], hl[3], hl[4]
local gui_bg = visual_bg_gui ~= '' and visual_bg_gui or 'NONE'
local cterm_bg = visual_bg_cterm ~= '' and visual_bg_cterm or 'NONE'
vim.cmd(
string.format(
'highlight default %s guifg=%s guibg=%s ctermfg=%d ctermbg=%s',
selected_hl,
gui_fg,
gui_bg,
cterm_fg,
cterm_bg
)
)
end
-- Selection highlight - use Directory/Number colors (better than green 'Added')
vim.cmd('highlight default link FFFSelected Directory')
local dir_fg_gui = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Directory')), 'fg', 'gui')
local dir_fg_cterm = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Directory')), 'fg', 'cterm')
if dir_fg_gui == '' or dir_fg_gui == '-1' then
-- Directory not defined, try Number
dir_fg_gui = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Number')), 'fg', 'gui')
dir_fg_cterm = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Number')), 'fg', 'cterm')
end
-- Fallback to blue if neither Directory nor Number have colors
local is_dark_bg = vim.o.background == 'dark'
local gui_fg = dir_fg_gui ~= '' and dir_fg_gui or (is_dark_bg and '#60A5FA' or '#0369A1')
local cterm_fg = dir_fg_cterm ~= '' and dir_fg_cterm or (is_dark_bg and '12' or '4')
local gui_bg = visual_bg_gui ~= '' and visual_bg_gui or 'NONE'
local cterm_bg = visual_bg_cterm ~= '' and visual_bg_cterm or 'NONE'
-- Create combined highlight: Directory/Number foreground + Visual background
vim.cmd(
string.format(
'highlight default FFFSelectedActive guifg=%s guibg=%s ctermfg=%s ctermbg=%s',
gui_fg,
gui_bg,
cterm_fg,
cterm_bg
)
)
end
return M
+651
View File
@@ -0,0 +1,651 @@
local M = {}
local utils = require('fff.utils')
local file_info_renderer = require('fff.file_picker.file_info')
local BORDER_PRESETS = {
single = { '', '', '', '', '', '', '', '' },
double = { '', '', '', '', '', '', '', '' },
rounded = { '', '', '', '', '', '', '', '' },
solid = { '', '', '', '', '', '', '', '' },
shadow = { '', '', ' ', ' ', ' ', ' ', ' ', '' },
none = { '', '', '', '', '', '', '', '' },
}
local T_JUNCTION_PRESETS = {
single = { '', '', '', '', '' },
double = { '', '', '', '', '' },
rounded = { '', '', '', '', '' },
solid = { '', '', '', '', '' },
shadow = { '', '', '', '', '' },
none = { '', '', '', '', '' },
}
-- Resolve which border preset to draw. `config.layout.border` overrides the
-- global `winborder`; when unset we fall back to `vim.o.winborder` so fff keeps
-- respecting the user's global preference.
local function get_border_chars(config)
local border = config and config.layout and config.layout.border
if border == nil or border == '' then border = vim.o.winborder end
if border == nil or border == '' then border = 'single' end
if BORDER_PRESETS[border] then return BORDER_PRESETS[border], T_JUNCTION_PRESETS[border] end
return BORDER_PRESETS.single, T_JUNCTION_PRESETS.single
end
--- Resolve a corner glyph based on adjacency flags. Each neighbour flag means
--- another float shares the edge meeting at that corner, so the corner needs
--- to extend a stem in that direction instead of being a plain corner.
local function resolve_corner(corner, chars, j, which, n)
local stems = {
tl = { down = chars[8] ~= '', right = chars[2] ~= '' },
tr = { down = chars[4] ~= '', left = chars[2] ~= '' },
bl = { up = chars[8] ~= '', right = chars[6] ~= '' },
br = { up = chars[4] ~= '', left = chars[6] ~= '' },
}
local s = stems[which]
local up = s.up or n.up
local down = s.down or n.down
local left = s.left or n.left
local right = s.right or n.right
local count = (up and 1 or 0) + (down and 1 or 0) + (left and 1 or 0) + (right and 1 or 0)
if count >= 4 then return j[5] end
if up and down and (left or right) then return left and j[2] or j[1] end
if left and right and (up or down) then return up and j[4] or j[3] end
return corner
end
local function resolve_prompt_position(config)
if config and config.layout and config.layout.prompt_position then
return utils.resolve_config_value(
config.layout.prompt_position,
vim.o.columns,
vim.o.lines,
function(value) return utils.is_one_of(value, { 'top', 'bottom' }) end,
'bottom',
'layout.prompt_position'
)
end
return 'bottom'
end
M.resolve_prompt_position = resolve_prompt_position
local function resolve_preview_position(config)
if config and config.layout and config.layout.preview_position then
local terminal_width = vim.o.columns
local position = utils.resolve_config_value(
config.layout.preview_position,
terminal_width,
vim.o.lines,
function(value) return utils.is_one_of(value, { 'left', 'right', 'top', 'bottom' }) end,
'right',
'layout.preview_position'
)
-- Flex wrap: when the terminal is narrower than `flex.size`, swap a
-- side-by-side preview to a stacked top/bottom one so columns stay legible.
local flex = config.layout.flex
if flex then
local size = flex.size or 80
local wrap = flex.wrap or 'top'
if terminal_width < size then return wrap end
end
return position
end
return 'right'
end
--- @param cfg table Layout config produced by compute
--- @return table layout dimensions and positions
function M.calculate_dimensions(cfg)
local BORDER_SIZE = 2
local PROMPT_HEIGHT = 2
local SEPARATOR_WIDTH = 1
local SEPARATOR_HEIGHT = 1
if not utils.is_one_of(cfg.preview_position, { 'left', 'right', 'top', 'bottom' }) then
error('Invalid preview position: ' .. tostring(cfg.preview_position))
end
local layout = {}
local preview_enabled = cfg.preview_enabled
if preview_enabled == nil then preview_enabled = true end
local total_width = math.max(0, cfg.total_width - BORDER_SIZE)
local total_height = math.max(0, cfg.total_height - BORDER_SIZE - PROMPT_HEIGHT)
if cfg.preview_position == 'left' then
local separator_width = preview_enabled and SEPARATOR_WIDTH or 0
local list_width = math.max(0, total_width - cfg.preview_width - separator_width)
local list_height = total_height
layout.list_col = cfg.start_col + cfg.preview_width + 2 -- +2 for borders (shared separator column)
layout.list_width = list_width
layout.list_height = list_height
layout.input_col = layout.list_col
layout.input_width = list_width
if preview_enabled then
layout.preview = {
col = cfg.start_col + 1,
row = cfg.start_row + 1,
width = cfg.preview_width,
height = list_height,
}
end
elseif cfg.preview_position == 'right' then
local separator_width = preview_enabled and SEPARATOR_WIDTH or 0
local list_width = math.max(0, total_width - cfg.preview_width - separator_width)
local list_height = total_height
layout.list_col = cfg.start_col + 1
layout.list_width = list_width
layout.list_height = list_height
layout.input_col = layout.list_col
layout.input_width = list_width
if preview_enabled then
layout.preview = {
col = cfg.start_col + list_width + 2, -- +2 for borders (shared separator column)
row = cfg.start_row + 1,
width = cfg.preview_width,
height = list_height,
}
end
elseif cfg.preview_position == 'top' then
local separator_height = preview_enabled and SEPARATOR_HEIGHT or 0
local list_height = math.max(0, total_height - cfg.preview_height - separator_height)
layout.list_col = cfg.start_col + 1
layout.list_width = total_width
layout.list_height = list_height
layout.input_col = layout.list_col
layout.input_width = total_width
layout.list_start_row = cfg.start_row + (preview_enabled and (cfg.preview_height + separator_height) or 0) + 1
if preview_enabled then
layout.preview = {
col = cfg.start_col + 1,
row = cfg.start_row + 1,
width = total_width,
height = cfg.preview_height,
}
end
else
local separator_height = preview_enabled and SEPARATOR_HEIGHT or 0
local list_height = math.max(0, total_height - cfg.preview_height - separator_height)
layout.list_col = cfg.start_col + 1
layout.list_width = total_width
layout.list_height = list_height
layout.input_col = layout.list_col
layout.input_width = total_width
layout.list_start_row = cfg.start_row + 1
if preview_enabled then
layout.preview = {
col = cfg.start_col + 1,
width = total_width,
height = cfg.preview_height,
}
end
end
if cfg.preview_position == 'left' or cfg.preview_position == 'right' then
if cfg.prompt_position == 'top' then
layout.input_row = cfg.start_row + 1
layout.list_row = cfg.start_row + PROMPT_HEIGHT + 1
else
layout.list_row = cfg.start_row + 1
layout.input_row = cfg.start_row + cfg.total_height - BORDER_SIZE
end
if layout.preview then
layout.preview.row = cfg.start_row + 1
layout.preview.height = cfg.total_height - BORDER_SIZE
end
else
local list_start_row = layout.list_start_row
if cfg.prompt_position == 'top' then
layout.input_row = list_start_row
layout.list_row = list_start_row + BORDER_SIZE
layout.list_height = math.max(0, layout.list_height - BORDER_SIZE)
else
layout.list_row = list_start_row
layout.input_row = list_start_row + layout.list_height + 1
end
if cfg.preview_position == 'bottom' and layout.preview then
if cfg.prompt_position == 'top' then
layout.preview.row = layout.list_row + layout.list_height + 1
else
layout.preview.row = layout.input_row + PROMPT_HEIGHT
end
end
end
-- Debug file_info panel. Only fits in side-by-side preview layouts; in
-- compact stacked layouts squeezing it in makes both panels unreadable.
local is_side_by_side = cfg.preview_position == 'left' or cfg.preview_position == 'right'
if cfg.debug_enabled and preview_enabled and layout.preview and is_side_by_side then
layout.file_info = {
width = layout.preview.width,
height = cfg.file_info_height,
col = layout.preview.col,
row = layout.preview.row,
}
-- Stack preview directly below file_info so they share a border row;
-- the shared row resolves to ├──...──┤ T-junctions instead of two corners.
local consumed = cfg.file_info_height + 1
layout.preview.row = layout.preview.row + consumed
layout.preview.height = math.max(3, layout.preview.height - consumed)
end
return layout
end
--- Build the per-window configs (for `nvim_open_win`) from a finished layout.
--- Handles all the corner/T-junction resolution so adjacent floats line up.
local function build_window_configs(layout, config, prompt_position, preview_position)
local border_chars, t_junctions = get_border_chars(config)
local has_preview = layout.preview ~= nil
local title = ' ' .. (config.title or 'FFFiles') .. ' '
local list_neighbour_input_top = prompt_position == 'top'
local list_neighbour_input_bottom = prompt_position == 'bottom'
local list_neighbour_preview_top = has_preview and preview_position == 'top'
local list_neighbour_preview_bottom = has_preview and preview_position == 'bottom'
local list_neighbour_preview_left = has_preview and preview_position == 'left'
local list_neighbour_preview_right = has_preview and preview_position == 'right'
local list_top_at_picker_top = (not list_neighbour_preview_top) and not list_neighbour_input_top
local list_bottom_at_picker_bottom = (not list_neighbour_preview_bottom) and not list_neighbour_input_bottom
local corners = {
tl = resolve_corner(border_chars[1], border_chars, t_junctions, 'tl', {
up = list_neighbour_preview_top or list_neighbour_input_top,
down = list_neighbour_preview_left,
left = list_neighbour_preview_left and list_top_at_picker_top,
}),
tr = resolve_corner(border_chars[3], border_chars, t_junctions, 'tr', {
up = list_neighbour_preview_top or list_neighbour_input_top,
down = list_neighbour_preview_right,
right = list_neighbour_preview_right and list_top_at_picker_top,
}),
br = resolve_corner(border_chars[5], border_chars, t_junctions, 'br', {
down = list_neighbour_preview_bottom or list_neighbour_input_bottom,
up = list_neighbour_preview_right,
right = list_neighbour_preview_right and list_bottom_at_picker_bottom,
}),
bl = resolve_corner(border_chars[7], border_chars, t_junctions, 'bl', {
down = list_neighbour_preview_bottom or list_neighbour_input_bottom,
up = list_neighbour_preview_left,
left = list_neighbour_preview_left and list_bottom_at_picker_bottom,
}),
}
local list_border = prompt_position == 'bottom'
and { corners.tl, border_chars[2], corners.tr, border_chars[4], '', '', '', border_chars[8] }
or {
corners.tl,
border_chars[2],
corners.tr,
border_chars[4],
corners.br,
border_chars[6],
corners.bl,
border_chars[8],
}
local list_cfg = {
relative = 'editor',
width = math.max(1, layout.list_width),
height = math.max(1, layout.list_height),
col = layout.list_col,
row = layout.list_row,
border = list_border,
style = 'minimal',
zindex = 52,
}
if prompt_position == 'bottom' then
list_cfg.title = title
list_cfg.title_pos = 'left'
end
local input_neighbour_preview_left = has_preview and preview_position == 'left'
local input_neighbour_preview_right = has_preview and preview_position == 'right'
local input_neighbour_preview_top = has_preview and preview_position == 'top' and prompt_position == 'top'
local input_neighbour_preview_bottom = has_preview and preview_position == 'bottom' and prompt_position == 'bottom'
local input_top_at_picker_top = prompt_position == 'top'
local input_bottom_at_picker_bottom = prompt_position == 'bottom'
local function tl_extends_up()
if prompt_position == 'bottom' then return true end -- list above
if input_neighbour_preview_top then return true end -- preview stack above
return false
end
local function tr_extends_up() return tl_extends_up() end
local function bl_extends_down()
if prompt_position == 'top' then return true end -- list below
if input_neighbour_preview_bottom then return true end -- preview stack below
return false
end
local function br_extends_down() return bl_extends_down() end
local ic = {
tl = resolve_corner(border_chars[1], border_chars, t_junctions, 'tl', {
up = tl_extends_up(),
down = input_neighbour_preview_left and input_top_at_picker_top,
left = input_neighbour_preview_left and input_top_at_picker_top,
}),
tr = resolve_corner(border_chars[3], border_chars, t_junctions, 'tr', {
up = tr_extends_up(),
down = input_neighbour_preview_right and input_top_at_picker_top,
right = input_neighbour_preview_right and input_top_at_picker_top,
}),
br = resolve_corner(border_chars[5], border_chars, t_junctions, 'br', {
down = br_extends_down(),
up = input_neighbour_preview_right and input_bottom_at_picker_bottom,
right = input_neighbour_preview_right and input_bottom_at_picker_bottom,
}),
bl = resolve_corner(border_chars[7], border_chars, t_junctions, 'bl', {
down = bl_extends_down(),
up = input_neighbour_preview_left and input_bottom_at_picker_bottom,
left = input_neighbour_preview_left and input_bottom_at_picker_bottom,
}),
}
-- Input always renders a full border. In top-prompt mode the bottom border
-- coincides with the list's top border row; the corner glyphs (`├` / `┤`)
-- are computed identically by both sides, but input is opened LAST so its
-- corners win the zindex tie at the column shared with file_info's left
-- vertical — without this, file_info's plain `│` would overdraw the
-- T-junction and leave a disconnected corner.
local input_border =
{ ic.tl, border_chars[2], ic.tr, border_chars[4], ic.br, border_chars[6], ic.bl, border_chars[8] }
local input_cfg = {
relative = 'editor',
width = math.max(1, layout.input_width),
height = 1,
col = layout.input_col,
row = layout.input_row,
border = input_border,
style = 'minimal',
zindex = 53,
}
if prompt_position == 'top' then
input_cfg.title = title
input_cfg.title_pos = 'left'
end
local preview_cfg = nil
if layout.preview then
local has_file_info_above = layout.file_info ~= nil
local pc = {
tl = resolve_corner(border_chars[1], border_chars, t_junctions, 'tl', {
right = preview_position == 'left',
up = has_file_info_above,
}),
tr = resolve_corner(border_chars[3], border_chars, t_junctions, 'tr', {
left = preview_position == 'right',
up = has_file_info_above,
}),
br = resolve_corner(border_chars[5], border_chars, t_junctions, 'br', {
left = preview_position == 'right',
}),
bl = resolve_corner(border_chars[7], border_chars, t_junctions, 'bl', {
right = preview_position == 'left',
}),
}
-- Top/bottom stacked previews share a row with the list; the matching
-- horizontal edge gets T-junctions on both ends.
if preview_position == 'top' then
pc.bl = resolve_corner(border_chars[7], border_chars, t_junctions, 'bl', { down = true })
pc.br = resolve_corner(border_chars[5], border_chars, t_junctions, 'br', { down = true })
elseif preview_position == 'bottom' then
pc.tl = resolve_corner(border_chars[1], border_chars, t_junctions, 'tl', { up = true })
pc.tr = resolve_corner(border_chars[3], border_chars, t_junctions, 'tr', { up = true })
end
local preview_border =
{ pc.tl, border_chars[2], pc.tr, border_chars[4], pc.br, border_chars[6], pc.bl, border_chars[8] }
preview_cfg = {
relative = 'editor',
width = math.max(1, layout.preview.width),
height = math.max(1, layout.preview.height),
col = layout.preview.col,
row = layout.preview.row,
style = 'minimal',
border = preview_border,
-- Title hidden when file_info renders above — its footer already says "Preview".
title = layout.file_info and '' or ' Preview ',
title_pos = 'left',
zindex = 51,
}
end
local file_info_cfg = nil
if layout.file_info then
local list_meets_fi_left = preview_position == 'right'
local list_meets_fi_right = preview_position == 'left'
local fc = {
tl = resolve_corner(border_chars[1], border_chars, t_junctions, 'tl', { left = list_meets_fi_left }),
tr = resolve_corner(border_chars[3], border_chars, t_junctions, 'tr', { right = list_meets_fi_right }),
bl = resolve_corner(border_chars[7], border_chars, t_junctions, 'bl', { down = true }),
br = resolve_corner(border_chars[5], border_chars, t_junctions, 'br', { down = true }),
}
local fi_border = { fc.tl, border_chars[2], fc.tr, border_chars[4], fc.br, border_chars[6], fc.bl, border_chars[8] }
file_info_cfg = {
relative = 'editor',
width = math.max(1, layout.file_info.width),
height = math.max(1, layout.file_info.height),
col = layout.file_info.col,
row = layout.file_info.row,
style = 'minimal',
border = fi_border,
title = ' File Info ',
title_pos = 'left',
-- Above the list/preview zindex so its borders win the shared rows.
zindex = 53,
}
end
return {
list = list_cfg,
input = input_cfg,
preview = preview_cfg,
file_info = file_info_cfg,
}
end
--- Compute the full layout + window configs for the picker. Auto-suppresses
--- the preview when the resulting list area would be smaller than
--- `config.layout.min_list_height`.
---
--- @param config table Resolved picker config (M.state.config)
--- @param preview_user_enabled boolean Whether the user's config has preview enabled
--- @return table { layout, win_configs, debug_enabled, preview_visible }
function M.compute(config, preview_user_enabled)
local debug_user_enabled = preview_user_enabled and config.debug and config.debug.enabled or false
local terminal_width = vim.o.columns
local terminal_height = vim.o.lines
local width_ratio = utils.resolve_config_value(
config.layout.width,
terminal_width,
terminal_height,
utils.is_valid_ratio,
0.8,
'layout.width'
)
local height_ratio = utils.resolve_config_value(
config.layout.height,
terminal_width,
terminal_height,
utils.is_valid_ratio,
0.8,
'layout.height'
)
local width = math.floor(terminal_width * width_ratio)
local height = math.floor(terminal_height * height_ratio)
-- Account for chrome (statusline, tabline, cmdheight) for edge-anchored positions
local has_tabline = vim.o.showtabline == 2 or (vim.o.showtabline == 1 and #vim.api.nvim_list_tabpages() > 1)
local has_statusline = vim.o.laststatus > 0
local top_edge = has_tabline and 1 or 0
local bottom_edge = terminal_height - vim.o.cmdheight - (has_statusline and 1 or 0)
local usable_height = bottom_edge - top_edge
height = math.min(height, usable_height)
local anchor = utils.resolve_config_value(
config.layout.anchor,
terminal_width,
terminal_height,
function(v)
return utils.is_one_of(v, {
'center',
'top_left',
'top',
'top_right',
'left',
'right',
'bottom_left',
'bottom',
'bottom_right',
})
end,
'center',
'layout.anchor'
)
-- Edge-flush anchors compensate for the +1 offset added by calculate_dimensions.
local center_col = math.floor((terminal_width - width) / 2)
local center_row = top_edge + math.floor((usable_height - height) / 2)
if width >= terminal_width then center_col = -1 end
if height >= usable_height then center_row = top_edge - 1 end
local anchor_positions = {
center = { col = center_col, row = center_row },
top_left = { col = -1, row = top_edge - 1 },
top = { col = center_col, row = top_edge - 1 },
top_right = { col = terminal_width - width - 2, row = top_edge - 1 },
left = { col = -1, row = center_row },
right = { col = terminal_width - width - 2, row = center_row },
bottom_left = { col = -1, row = bottom_edge - height - 1 },
bottom = { col = center_col, row = bottom_edge - height - 1 },
bottom_right = { col = terminal_width - width - 2, row = bottom_edge - height - 1 },
}
local pos = anchor_positions[anchor] or anchor_positions.center
local col = pos.col
local row = pos.row
-- Manual ratio overrides (backwards compat)
if config.layout.col ~= nil then
local col_ratio = utils.resolve_config_value(
config.layout.col,
terminal_width,
terminal_height,
utils.is_valid_ratio,
col / terminal_width,
'layout.col'
)
col = math.floor(terminal_width * col_ratio)
end
if config.layout.row ~= nil then
local row_ratio = utils.resolve_config_value(
config.layout.row,
terminal_width,
terminal_height,
utils.is_valid_ratio,
row / terminal_height,
'layout.row'
)
row = math.floor(terminal_height * row_ratio)
end
local prompt_position = resolve_prompt_position(config)
local preview_position = resolve_preview_position(config)
local preview_size_ratio = utils.resolve_config_value(
config.layout.preview_size,
terminal_width,
terminal_height,
utils.is_valid_ratio,
0.4,
'layout.preview_size'
)
local is_fullscreen = width >= terminal_width and height >= usable_height
-- Panel sits above preview, so its width matches preview width. Ask the
-- renderer how many rows it'll draw so we don't reserve a gap row.
local preview_width_predicted = preview_user_enabled and math.floor(width * preview_size_ratio) or 0
local file_info_height = 0
if debug_user_enabled then
file_info_height = file_info_renderer.calculate_required_height(
config.debug and config.debug.show_file_info,
preview_width_predicted
)
end
local dim_cfg = {
total_width = width,
-- Top/bottom preview with prompt-top has a 2-row chrome over-subtraction in
-- calculate_dimensions (BORDER_SIZE is subtracted twice). Compensate at fullscreen.
total_height = (
is_fullscreen
and prompt_position == 'top'
and (preview_position == 'top' or preview_position == 'bottom')
)
and height + 2
or height,
start_col = col,
start_row = row,
preview_position = preview_position,
prompt_position = prompt_position,
debug_enabled = debug_user_enabled and file_info_height > 0,
preview_enabled = preview_user_enabled,
preview_width = preview_width_predicted,
preview_height = preview_user_enabled and math.floor(height * preview_size_ratio) or 0,
separator_width = 3,
file_info_height = file_info_height,
}
local layout = M.calculate_dimensions(dim_cfg)
-- Auto-hide preview when the list area would be too cramped to be useful.
-- Recompute giving the list all the available space.
local min_list_height = utils.resolve_config_value(
config.layout.min_list_height,
terminal_width,
terminal_height,
function(v) return type(v) == 'number' and v >= 0 end,
10,
'layout.min_list_height'
)
local debug_enabled = debug_user_enabled
if preview_user_enabled and layout.preview and min_list_height > 0 and layout.list_height < min_list_height then
dim_cfg.preview_enabled = false
dim_cfg.preview_width = 0
dim_cfg.preview_height = 0
dim_cfg.debug_enabled = false
layout = M.calculate_dimensions(dim_cfg)
debug_enabled = false
end
local win_configs = build_window_configs(layout, config, prompt_position, preview_position)
return {
layout = layout,
win_configs = win_configs,
debug_enabled = debug_enabled,
preview_visible = layout.preview ~= nil,
}
end
return M
+138
View File
@@ -0,0 +1,138 @@
-- Renders list separator at any index, designed to be floating on top of list renderer
local M = {}
local LEFT_PADDING = 2
local RIGHT_PADDING = 1
-- overflow BOTH borders on left and right
local OVERFLOW_TOTAL = 2
---@class fff.list_separator.State
---@field buf integer|nil
---@field win integer|nil
---@field ns_id integer
---@field last string|nil
local state = {
buf = nil,
win = nil,
ns_id = 0,
last = nil,
}
--- @class FffSeparatorOpts
--- @field list_win number List window handle (used to read its config)
--- @field row number 1-based screen row where the separator sits (relative to editor)
--- @field text string Label text rendered between the dashes (callers prefix arrow glyphs themselves)
--- @field text_hl string Highlight group for the label
--- @field border_hl string Highlight group for the dashes / junctions
function M.init(ns_id) state.ns_id = ns_id end
---@return integer
local function get_or_create_buf()
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
state.buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = state.buf })
end
return state.buf --[[@as integer]]
end
--- @param total_width number Width of the float in cells (incl. `├` and `┤`)
--- @param text string Label text (we add the surrounding spaces)
--- @return string content
--- @return number label_byte_start Byte offset where the highlighted label begins
--- @return number label_byte_len Byte length of the highlighted label run
local function build_line(total_width, text)
local label = ' ' .. text .. ' '
local label_disp = vim.fn.strdisplaywidth(label)
-- 2 cells consumed by `├` and `┤`, plus LEFT_PADDING + RIGHT_PADDING dashes.
local inner = math.max(0, total_width - 2 - LEFT_PADDING - RIGHT_PADDING - label_disp)
local left = string.rep('', LEFT_PADDING)
local right = string.rep('', inner + RIGHT_PADDING)
local content = '' .. left .. label .. right .. ''
local label_byte_start = #('' .. left)
return content, label_byte_start, #label
end
--- Render or reposition the separator
--- @param opts FffSeparatorOpts
function M.update(opts)
local list_cfg = vim.api.nvim_win_get_config(opts.list_win)
local list_col = list_cfg.col
local list_width = list_cfg.width
-- Span the full bordered list footprint: `├` lands on the left vertical,
-- `┤` on the right. nvim_win_get_config().col is already the column of the
-- left border, so we don't shift further.
local total_width = list_width + OVERFLOW_TOTAL
local col = list_col
local row = opts.row
local key = string.format('%d|%d|%d|%s|%s|%s', row, col, total_width, opts.text, opts.text_hl, opts.border_hl)
if state.last == key and state.win and vim.api.nvim_win_is_valid(state.win) then return end
local buf = get_or_create_buf()
local content, label_byte_start, label_byte_len = build_line(total_width, opts.text)
vim.api.nvim_set_option_value('modifiable', true, { buf = buf })
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { content })
vim.api.nvim_buf_clear_namespace(buf, state.ns_id, 0, -1)
vim.api.nvim_buf_set_extmark(buf, state.ns_id, 0, 0, {
end_row = 1,
end_col = 0,
hl_group = opts.border_hl,
hl_eol = false,
})
if label_byte_len > 0 then
vim.api.nvim_buf_set_extmark(buf, state.ns_id, 0, label_byte_start, {
end_row = 0,
end_col = label_byte_start + label_byte_len,
hl_group = opts.text_hl,
})
end
vim.api.nvim_set_option_value('modifiable', false, { buf = buf })
-- the actual fake floating window
local win_cfg = {
relative = 'editor',
width = total_width,
height = 1,
row = row,
col = col,
style = 'minimal',
border = 'none',
focusable = false,
zindex = 250,
}
if state.win and vim.api.nvim_win_is_valid(state.win) then
vim.api.nvim_win_set_config(state.win, win_cfg)
else
state.win = vim.api.nvim_open_win(buf, false, win_cfg)
vim.api.nvim_set_option_value('winhighlight', 'Normal:Normal', { win = state.win })
end
state.last = key
end
--- Hide the separator if visible. Safe to call repeatedly.
--- @return boolean was_visible True if a hide actually happened
function M.hide()
local was_visible = false
if state.win and vim.api.nvim_win_is_valid(state.win) then
pcall(vim.api.nvim_win_close, state.win, true)
was_visible = true
end
state.win = nil
state.last = nil
return was_visible
end
function M.cleanup()
M.hide()
if state.buf and vim.api.nvim_buf_is_valid(state.buf) then
pcall(vim.api.nvim_buf_delete, state.buf, { force = true })
end
state.buf = nil
end
return M
+308
View File
@@ -0,0 +1,308 @@
local M = {}
--- Jump to a location in the current buffer
--- @param location table|nil Location data from search results
function M.jump_to_location(location)
if not location then return end
local current_buf = vim.api.nvim_get_current_buf()
local line_count = vim.api.nvim_buf_line_count(current_buf)
if location.line then
local target_line = math.max(1, math.min(location.line, line_count))
local target_col = location.col and math.max(0, location.col - 1) or 0
vim.api.nvim_win_set_cursor(0, { target_line, target_col })
vim.cmd('normal! zz')
elseif location.start and location['end'] then
-- Extract line numbers from nested structure
local start_line = math.max(1, math.min(location.start.line, line_count))
local end_line = math.max(start_line, math.min(location['end'].line, line_count))
-- start in the visual mode and selecting the range backwards so the cursor ends up at the start
vim.api.nvim_win_set_cursor(0, { end_line, 0 })
vim.cmd('normal! V')
if end_line > start_line then vim.cmd('normal! ' .. (end_line - start_line) .. 'k') end
vim.cmd('normal! zz')
end
end
--- Highlight a location range in a buffer using extmarks
--- @param bufnr number Buffer number
--- @param location table|nil Location data from search results
--- @param namespace number Namespace for extmarks
--- @return table|nil Highlight extmark details for cleanup
function M.highlight_location(bufnr, location, namespace)
if not location or not vim.api.nvim_buf_is_valid(bufnr) then return nil end
local line_count = vim.api.nvim_buf_line_count(bufnr)
local extmarks = {}
-- Grep mode: highlight all occurrences of the search pattern across visible lines
if location.grep_query and location.grep_query ~= '' then
return M.highlight_grep_matches(bufnr, location, namespace)
end
if location.line then
local target_line = math.max(1, math.min(location.line, line_count))
if location.col then
local target_col = math.max(0, location.col - 1)
local line_content = vim.api.nvim_buf_get_lines(bufnr, target_line - 1, target_line, false)[1] or ''
local end_col = math.min(target_col + 1, #line_content)
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, target_col, {
end_col = end_col,
hl_group = 'IncSearch', -- inc search are better visible for a single chars
line_hl_group = 'CursorLine',
number_hl_group = 'CursorLineNr',
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end
else
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, 0, {
line_hl_group = 'Visual',
number_hl_group = 'CursorLineNr',
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end
end
elseif location.start and location['end'] then
local start_line = math.max(1, math.min(location.start.line, line_count))
local end_line = math.max(start_line, math.min(location['end'].line, line_count))
-- Check if we have column information for exact range highlighting
if location.start.col and location['end'].col then
if start_line == end_line then
-- Single line range with columns: highlight exact character range
local start_col = math.max(0, location.start.col - 1)
local end_col = location['end'].col - 1
local line_content = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, start_line, false)[1] or ''
end_col = math.min(end_col, #line_content)
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, start_line - 1, start_col, {
end_col = end_col,
hl_group = 'IncSearch',
line_hl_group = 'CursorLine',
number_hl_group = 'CursorLineNr',
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = start_line - 1 }) end
else
-- Multi-line range with exact columns: highlight precise ranges
for line = start_line, end_line do
local line_start_col, line_end_col
if line == start_line then
-- First line: from start_col to end of line
line_start_col = math.max(0, location.start.col - 1)
local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or ''
line_end_col = #line_content
elseif line == end_line then
-- Last line: from beginning to end_col
line_start_col = 0
line_end_col = location['end'].col - 1
local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or ''
line_end_col = math.min(line_end_col, #line_content)
else
-- Middle lines: entire line
line_start_col = 0
local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or ''
line_end_col = #line_content
end
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, line - 1, line_start_col, {
end_col = line_end_col,
hl_group = 'Visual',
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = line - 1 }) end
end
end
else
-- Multi-line or no columns: highlight entire lines
for line = start_line, end_line do
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, line - 1, 0, {
line_hl_group = 'Visual',
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = line - 1 }) end
end
end
end
return #extmarks > 0 and extmarks or nil
end
--- Highlight all occurrences of a grep pattern in the preview buffer.
--- For plain text and regex modes: highlights every match on all loaded lines
--- using Lua string.find with the query text.
--- For fuzzy mode: uses the pre-computed match byte offsets from Rust on the
--- target line only, since the fuzzy needle (e.g. "shcema") won't match via
--- literal search against the actual content (e.g. "schema").
--- @param bufnr number Buffer number
--- @param location table Location with .grep_query, .line, optional .col, optional .fuzzy_match_ranges
--- @param namespace number Namespace for extmarks
--- @return table|nil Highlight extmark details for cleanup
function M.highlight_grep_matches(bufnr, location, namespace)
if not vim.api.nvim_buf_is_valid(bufnr) then return nil end
local line_count = vim.api.nvim_buf_line_count(bufnr)
local extmarks = {}
local grep_hl = require('fff.conf').get().hl.grep_match or 'IncSearch'
-- Pin CursorLine + CursorLineNr to the target match line via extmark, so
-- the highlight stays anchored when the user pages the preview viewport
-- with <C-d>/<C-u>. The cursor itself moves with paging, but the match
-- line stays styled until it scrolls out of view.
if location.line then
local target_line = math.max(1, math.min(location.line, line_count))
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, 0, {
line_hl_group = 'CursorLine',
number_hl_group = 'CursorLineNr',
priority = 999,
})
if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end
end
-- Fuzzy mode: use pre-computed byte offsets from Rust's match_indices.
-- These are the exact matched character positions within the line, already
-- computed by the SIMD scoring + reference smith-waterman traceback.
-- We only highlight the target line since each fuzzy result has its own
-- unique set of matched positions.
if location.fuzzy_match_ranges and location.line then
local target_line = math.max(1, math.min(location.line, line_count))
for _, range in ipairs(location.fuzzy_match_ranges) do
local start_byte = range[1] -- 0-based byte offset
local end_byte = range[2] -- 0-based exclusive end
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, start_byte, {
end_col = end_byte,
hl_group = grep_hl,
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end
end
-- Fuzzy mode: only target line has matches; skip the plain-text scan below.
return #extmarks > 0 and extmarks or nil
end
local query = location.grep_query
-- Use the Rust GrepConfig parser as the single source of truth for
-- stripping constraint tokens. This avoids duplicating constraint
-- detection in Lua, which would break whenever a new token type is added.
local fuzzy = require('fff.fuzzy')
local parsed = fuzzy.parse_grep_query(query)
local search_text = parsed.grep_text
if search_text == '' then search_text = query end
if not search_text or search_text == '' then return nil end
-- Build case-insensitive pattern if the query has no uppercase (smart case)
local has_upper = search_text:match('[A-Z]')
-- Highlight pattern occurrences in a window around the target line.
-- Limit to ±200 lines from target to keep it fast for large files.
local scan_start = 1
local scan_end = line_count
if location.line then
scan_start = math.max(1, location.line - 200)
scan_end = math.min(line_count, location.line + 200)
end
local lines = vim.api.nvim_buf_get_lines(bufnr, scan_start - 1, scan_end, false)
for idx, line in ipairs(lines) do
local i = scan_start + idx - 1
local search_line = has_upper and line or line:lower()
local search_pat = has_upper and search_text or search_text:lower()
local start_pos = 1
while true do
local s, e = search_line:find(search_pat, start_pos, true)
if not s then break end
-- s and e are 1-based byte positions; extmarks need 0-based
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, i - 1, s - 1, {
end_col = e,
hl_group = grep_hl,
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = i - 1 }) end
start_pos = e + 1
end
end
return #extmarks > 0 and extmarks or nil
end
--- Clear location highlights from a buffer
--- @param bufnr number Buffer number
--- @param namespace number Namespace for extmarks
function M.clear_location_highlights(bufnr, namespace)
if vim.api.nvim_buf_is_valid(bufnr) then vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1) end
end
--- Get the target line for scrolling preview to location
--- @param location table|nil Location data from search results
--- @return number|nil Target line number (1-indexed) for scrolling
function M.get_target_line(location)
if not location then return nil end
if location.line then
return location.line
elseif location.start then
return location.start.line
end
return nil
end
--- Check if location is valid for a buffer
--- @param location table|nil Location data
--- @param bufnr number Buffer number
--- @return boolean True if location is valid for the buffer
function M.is_valid_location(location, bufnr)
if not location or not vim.api.nvim_buf_is_valid(bufnr) then return false end
local line_count = vim.api.nvim_buf_line_count(bufnr)
if location.line then
return location.line > 0 and location.line <= line_count
elseif location.start and location['end'] then
return location.start.line > 0 and location.start.line <= line_count
end
return false
end
--- Format location for display
--- @param location table|nil Location data
--- @return string Formatted location string
function M.format_location(location)
if not location then return '' end
if location.line and location.col then
return string.format(':%d:%d', location.line, location.col)
elseif location.line then
return string.format(':%d', location.line)
elseif location.start and location['end'] then
-- Handle nested structure with optional column information
if location.start.col and location['end'].col then
return string.format(
':%d:%d-%d:%d',
location.start.line,
location.start.col,
location['end'].line,
location['end'].col
)
else
return string.format(':%d-%d', location.start.line, location['end'].line)
end
end
return ''
end
return M
+653
View File
@@ -0,0 +1,653 @@
local M = {}
M.state = { initialized = false }
--- Setup the file picker with the given configuration
--- @param config table Configuration options
function M.setup(config) vim.g.fff = config end
--- Find files in current directory.
--- When opts.resume is true, resumes the last find_files picker (or opens a new one if none saved).
--- When opts.on_submit is set, it replaces the default `:edit` action on user selection.
--- Signature: `fun(item: table, ctx: { action: string, path: string, relative_path: string, location: table|nil, query: string, mode: string|nil })`.
--- @param opts? table Optional configuration {renderer = custom_renderer, resume = boolean, on_submit = function}
function M.find_files(opts)
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui.picker_ui')
if not picker_ok then
vim.notify('Failed to load picker UI: ' .. picker_ui, vim.log.levels.ERROR)
return
end
if opts and opts.resume then
picker_ui.resume_find_files(opts)
return
end
picker_ui.open(opts)
end
--- Live grep: search file contents in the current directory.
--- When opts.resume is true, resumes the last live_grep picker (or opens a new one if none saved).
--- @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, resume?: boolean} Optional configuration overrides
function M.live_grep(opts)
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui.picker_ui')
if not picker_ok then
vim.notify('Failed to load picker UI: ' .. picker_ui, vim.log.levels.ERROR)
return
end
if opts and opts.resume then
picker_ui.resume_live_grep(opts)
return
end
local config = require('fff.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 {})
local picker_opts = vim.tbl_deep_extend('force', {
title = 'Live Grep',
mode = 'grep',
renderer = grep_renderer,
grep_config = grep_config,
}, opts or {})
picker_ui.open(picker_opts)
end
--- Live grep prefilled with the current word (normal mode) or the visual selection (visual mode).
--- @param opts? table Forwarded to `live_grep`; `query` is overwritten by the resolved text.
function M.live_grep_under_cursor(opts)
local mode = vim.fn.mode()
local query
if mode == 'v' or mode == 'V' or mode == '\22' then
-- Exit visual so '< / '> marks settle, then read the range directly —
-- no yank, no register clobber.
vim.cmd('normal! ' .. vim.api.nvim_replace_termcodes('<Esc>', true, false, true))
local s = vim.fn.getpos("'<")
local e = vim.fn.getpos("'>")
local lines = vim.fn.getregion(s, e, { type = mode })
query = table.concat(lines, ' ')
else
query = vim.fn.expand('<cword>')
end
opts = vim.tbl_deep_extend('force', opts or {}, { query = query })
M.live_grep(opts)
end
--- Changes the directory indexed by the file picker to the git root and opens the file picker
--- @deprecated Use `find_files` instead
function M.find_in_git_root()
local fuzzy = require('fff.core').ensure_initialized()
local ok, git_root = pcall(fuzzy.get_git_root)
if not ok or not git_root then
vim.notify('Not in a git repository', vim.log.levels.WARN)
return
end
M.find_files_in_dir(git_root)
end
--- Clear FFF caches (both in-memory state and on-disk database files)
--- @param scope? string Cache scope: all|frecency|files
function M.clear_cache(scope)
local fuzzy = require('fff.fuzzy')
if not scope or scope == '' then scope = 'all' end
local errors = {}
if scope == 'all' or scope == 'files' then
local ok, err = pcall(fuzzy.cleanup_file_picker)
if not ok then table.insert(errors, 'cleanup file picker: ' .. tostring(err)) end
end
if scope == 'all' or scope == 'frecency' then
local ok, err = pcall(fuzzy.destroy_frecency_db)
if not ok then table.insert(errors, 'destroy frecency db: ' .. tostring(err)) end
ok, err = pcall(fuzzy.destroy_query_db)
if not ok then table.insert(errors, 'destroy query db: ' .. tostring(err)) end
end
if #errors > 0 then
vim.notify('FFF: errors clearing cache: ' .. table.concat(errors, '; '), vim.log.levels.ERROR)
return false
end
vim.notify('Cleared FFF cache: ' .. scope, vim.log.levels.INFO)
return true
end
--- Trigger rescan of files in the current directory
function M.scan_files()
local fuzzy = require('fff.core').ensure_initialized()
local ok = pcall(fuzzy.scan_files)
if not ok then vim.notify('Failed to scan files', vim.log.levels.ERROR) end
end
--- Refresh git status for the active file lock
function M.refresh_git_status()
local fuzzy = require('fff.core').ensure_initialized()
local ok, updated_files_count = pcall(fuzzy.refresh_git_status)
if ok then
vim.notify('Refreshed git status for ' .. tostring(updated_files_count) .. ' files', vim.log.levels.INFO)
else
vim.notify('Failed to refresh git status', vim.log.levels.ERROR)
end
end
--- Search files programmatically
--- @param query string Search query
--- @param max_results number Maximum number of results
--- @return table List of matching files
function M.search(query, max_results)
local fuzzy = require('fff.core').ensure_initialized()
local config = require('fff.conf').get()
max_results = max_results or config.max_results
local max_threads = config.max_threads or 4
local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100
local min_combo_count = config.history and config.history.min_combo_count or 3
-- Args: query, max_threads, current_file, combo_boost_score_multiplier, min_combo_count, offset, page_size
local ok, search_result = pcall(
fuzzy.fuzzy_search_files,
query,
max_threads,
nil,
combo_boost_score_multiplier,
min_combo_count,
0,
max_results
)
if ok and search_result.items then return search_result.items end
return {}
end
--- @class fff.FileSearchOpts
--- @field mode? "files"|"directories"|"mixed" Item type to search (default: 'files').
--- @field max_results? number Max items per page (default: config.max_results).
--- @field page? number 0-based page index (default: 0).
--- @field current_file? string Path to deprioritize (default: nil).
--- @field max_threads? number Worker threads (default: config.max_threads).
--- @field combo_boost_score_multiplier? number Override history combo boost.
--- @field min_combo_count? number Override history min_combo_count.
--- @field cwd? string If set and different from the current indexed root, switch the index to this directory before searching. Implies waiting for the new scan unless `wait_for_index_ms = 0`.
--- @field wait_for_index_ms? number Block up to this many ms for the index to be ready (default: 10000 when `cwd` triggers a re-index, 0 otherwise). Set to 0 to never block.
--- Switch the indexed root if `cwd` is set and different from the current
--- `base_path`, then optionally block until the new scan completes.
--- Returns `true` when the index is ready (or no wait requested), or `false`
--- with an error message on timeout / invalid cwd.
--- @param cwd string|nil
--- @param wait_for_index_ms number|nil
--- @return boolean ok, string? err
local function ensure_indexed(cwd, wait_for_index_ms)
-- ensure_initialized is idempotent; first call kicks off the initial scan
-- at config.base_path.
require('fff.core').ensure_initialized()
local config = require('fff.conf').get()
local is_windows = vim.fn.has('win32') == 1
local function canon(p)
if not p or p == '' then return '' end
local abs = vim.fn.fnamemodify(vim.fn.expand(p), ':p')
abs = (abs:gsub('[/\\]+$', ''))
-- fs_realpath resolves Windows 8.3 short names (RUNNER~1 -> runneradmin)
-- so picker base_path (canonicalized in rust) compares equal to the cwd
-- argument. fnamemodify(':p') alone keeps the short form on Windows.
local realpath_ok, realpath = pcall(vim.uv.fs_realpath, abs)
if realpath_ok and realpath then abs = realpath end
local normalized = vim.fs.normalize(abs)
if is_windows then normalized = normalized:lower() end
return normalized
end
local cwd_triggered_reindex = false
if cwd and cwd ~= '' then
local expanded = vim.fn.expand(cwd)
if vim.fn.isdirectory(expanded) ~= 1 then return false, 'cwd does not exist: ' .. expanded end
if canon(config.base_path) ~= canon(expanded) then
if not require('fff.core').change_indexing_directory(expanded) then
return false, 'failed to change indexing directory to ' .. expanded
end
cwd_triggered_reindex = true
end
end
-- Default: only wait when cwd actually swapped the picker. Callers can
-- pass wait_for_index_ms explicitly to force a wait on first-time init too.
local wait_ms = wait_for_index_ms
if wait_ms == nil then wait_ms = cwd_triggered_reindex and 10000 or 0 end
if wait_ms <= 0 then return true end
local fff_rust = require('fff.rust')
-- The picker swap runs on a background thread; wait_for_scan reads the
-- picker pointer once at entry, so polling health_check first guarantees
-- we wait on the new picker rather than racing the old one.
if cwd_triggered_reindex then
local target = canon(cwd)
local deadline = vim.uv.hrtime() + wait_ms * 1e6
local matched = false
while vim.uv.hrtime() < deadline do
local ok, health = pcall(fff_rust.health_check, target)
if ok and health and health.file_picker and health.file_picker.base_path then
if canon(health.file_picker.base_path) == target then
matched = true
break
end
end
vim.wait(20, function() return false end)
end
if not matched then return false, 'timeout waiting for re-index swap' end
-- Subtract the time we spent polling so the scan wait stays bounded.
local remaining = math.max(0, math.floor((deadline - vim.uv.hrtime()) / 1e6))
if remaining == 0 then return false, 'timeout waiting for index scan' end
wait_ms = remaining
end
local scan_ok = require('fff.file_picker').wait_for_initial_scan(wait_ms)
if not scan_ok then return false, 'timeout waiting for index scan' end
return true
end
--- Programmatic file search.
--- Returns the full structured result so callers can read scores, totals,
--- and (for `files`/`mixed` modes) the parsed `location`.
---
--- For `mixed` mode each item has a `type` field of `"file"` or `"directory"`.
--- @param query string Search query (constraint syntax supported)
--- @param opts? fff.FileSearchOpts
--- @return { items: table[], scores: table[], total_matched: number, total_files?: number, total_dirs?: number, location?: table }
function M.file_search(query, opts)
vim.validate({
query = { query, 'string' },
opts = { opts, 'table', true },
})
opts = opts or {}
local indexed_ok, err = ensure_indexed(opts.cwd, opts.wait_for_index_ms)
if not indexed_ok then
vim.notify('FFF file_search: ' .. err, vim.log.levels.ERROR)
return { items = {}, scores = {}, total_matched = 0 }
end
local fuzzy = require('fff.fuzzy')
local config = require('fff.conf').get()
local mode = opts.mode or 'files'
local max_threads = opts.max_threads or config.max_threads or 4
local page_size = opts.max_results or config.max_results or 100
local page_index = opts.page or 0
local current_file = opts.current_file
local combo_boost = opts.combo_boost_score_multiplier
or (config.history and config.history.combo_boost_score_multiplier)
or 100
local min_combo = opts.min_combo_count or (config.history and config.history.min_combo_count) or 3
local empty = { items = {}, scores = {}, total_matched = 0 }
if mode == 'files' then
local offset = page_index * page_size
local ok, result =
pcall(fuzzy.fuzzy_search_files, query, max_threads, current_file, combo_boost, min_combo, offset, page_size)
if not ok then
vim.notify('FFF file_search failed: ' .. tostring(result), vim.log.levels.ERROR)
return empty
end
return result
elseif mode == 'directories' then
local ok, result = pcall(fuzzy.fuzzy_search_directories, query, max_threads, current_file, page_index, page_size)
if not ok then
vim.notify('FFF file_search(directories) failed: ' .. tostring(result), vim.log.levels.ERROR)
return empty
end
return result
elseif mode == 'mixed' then
local ok, result =
pcall(fuzzy.fuzzy_search_mixed, query, max_threads, current_file, combo_boost, min_combo, page_index, page_size)
if not ok then
vim.notify('FFF file_search(mixed) failed: ' .. tostring(result), vim.log.levels.ERROR)
return empty
end
return result
else
error("fff.file_search: opts.mode must be 'files', 'directories', or 'mixed', got " .. tostring(mode))
end
end
--- @class fff.ContentSearchOpts
--- @field mode? "plain"|"regex"|"fuzzy" Grep mode (default: 'plain').
--- @field max_file_size? number Skip files larger than N bytes (default: config.grep.max_file_size).
--- @field max_matches_per_file? number Cap matches per file, 0 = unlimited (default: config.grep.max_matches_per_file).
--- @field smart_case? boolean Case-insensitive when query is all lowercase (default: config.grep.smart_case).
--- @field page_size? number Max matches returned (default: 50).
--- @field file_offset? number File-based pagination offset (default: 0).
--- @field time_budget_ms? number Max wall-clock time, 0 = unlimited (default: config.grep.time_budget_ms).
--- @field trim_whitespace? boolean Strip leading whitespace from matched lines (default: config.grep.trim_whitespace).
--- @field cwd? string Switch indexed root before grepping (same semantics as `file_search`).
--- @field wait_for_index_ms? number Block up to this many ms for the index to be ready.
--- Programmatic content (grep) search.
--- Returns the full structured `GrepResult` (items, totals, regex fallback).
--- @param query string Grep query (`*.rs pattern`, glob constraints, etc. supported)
--- @param opts? fff.ContentSearchOpts
--- @return { items: table[], total_matched: number, total_files_searched: number, total_files: number, filtered_file_count: number, next_file_offset: number, regex_fallback_error?: string }
function M.content_search(query, opts)
vim.validate({
query = { query, 'string' },
opts = { opts, 'table', true },
})
opts = opts or {}
local mode = opts.mode or 'plain'
if mode ~= 'plain' and mode ~= 'regex' and mode ~= 'fuzzy' then
error("fff.content_search: opts.mode must be 'plain', 'regex', or 'fuzzy', got " .. tostring(mode))
end
local empty = {
items = {},
total_matched = 0,
total_files_searched = 0,
total_files = 0,
filtered_file_count = 0,
next_file_offset = 0,
}
local indexed_ok, err = ensure_indexed(opts.cwd, opts.wait_for_index_ms)
if not indexed_ok then
vim.notify('FFF content_search: ' .. err, vim.log.levels.ERROR)
return empty
end
local config = require('fff.conf').get()
local grep_cfg = config.grep or {}
local grep = require('fff.picker_ui.grep_renderer')
local merged_grep_cfg = {
max_file_size = opts.max_file_size or grep_cfg.max_file_size,
max_matches_per_file = opts.max_matches_per_file or grep_cfg.max_matches_per_file,
smart_case = opts.smart_case == nil and grep_cfg.smart_case or opts.smart_case,
time_budget_ms = opts.time_budget_ms or grep_cfg.time_budget_ms,
trim_whitespace = opts.trim_whitespace == nil and grep_cfg.trim_whitespace or opts.trim_whitespace,
}
local ok, result = pcall(grep.search, query, opts.file_offset or 0, opts.page_size or 50, merged_grep_cfg, mode)
if not ok then
vim.notify('FFF content_search failed: ' .. tostring(result), vim.log.levels.ERROR)
return empty
end
return result
end
--- Search and show results in a nice format
--- @param query string Search query
function M.search_and_show(query)
if not query or query == '' then
M.find_files()
return
end
local results = M.search(query, 20)
if #results == 0 then
print('🔍 No files found matching "' .. query .. '"')
return
end
-- Filter out directories (should already be done by Rust, but just in case)
local files = {}
for _, item in ipairs(results) do
if not item.is_dir then table.insert(files, item) end
end
if #files == 0 then
print('🔍 No files found matching "' .. query .. '"')
return
end
print('🔍 Found ' .. #files .. ' files matching "' .. query .. '":')
for i, file in ipairs(files) do
if i <= 15 then
local file_extension = vim.fn.fnamemodify(file.name, ':e')
local icon = file_extension ~= '' and '.' .. file_extension or '📄'
local frecency = file.total_frecency_score > 0 and '' .. file.total_frecency_score or ''
print(' ' .. i .. '. ' .. icon .. ' ' .. file.relative_path .. frecency)
end
end
if #files > 15 then print(' ... and ' .. (#files - 15) .. ' more files') end
print('Use :FFFFind to browse all files')
end
--- Get file preview
--- @param file_path string Path to the file
--- @return string|nil File content or nil if failed
function M.get_preview(file_path)
local preview = require('fff.file_picker.preview')
local temp_buf = vim.api.nvim_create_buf(false, true)
local success = preview.preview(file_path, temp_buf)
if not success then
vim.api.nvim_buf_delete(temp_buf, { force = true })
return nil
end
local lines = vim.api.nvim_buf_get_lines(temp_buf, 0, -1, false)
vim.api.nvim_buf_delete(temp_buf, { force = true })
return table.concat(lines, '\n')
end
--- Find files in a specific directory
--- @param directory string Directory path to search in
function M.find_files_in_dir(directory)
if not directory then
vim.notify('Directory path required for find_files_in_dir', vim.log.levels.ERROR)
return
end
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui.picker_ui')
if picker_ok then
picker_ui.open({
title = 'Files in ' .. vim.fn.fnamemodify(directory, ':t'),
cwd = directory,
})
else
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
end
end
--- Change the base directory for the file picker
--- @param new_path string New directory path to use as base
--- @return boolean `true` if successful, `false` otherwise
function M.change_indexing_directory(new_path) return require('fff.core').change_indexing_directory(new_path) end
--- Resume the most recently closed picker (find_files or live_grep).
--- Similar to Telescope's `require('telescope.builtin').resume()`.
---@return boolean true if a picker was resumed, false if there is nothing to resume
function M.resume()
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui.picker_ui')
if not picker_ok then
vim.notify('Failed to load picker UI: ' .. picker_ui, vim.log.levels.ERROR)
return false
end
return picker_ui.resume()
end
-- Strip wrapper punctuation that frequently surrounds paths in prose: leading
-- markdown-link `[`, parens `(`, brackets `<`, quotes; trailing sentence
-- punctuation. We additionally truncate at the first closing wrapper so a
-- cWORD like `[file.lua](./somewhere)` collapses to just `file.lua`. We
-- deliberately keep `:` and digits inside the word so `path:line:col`
-- suffixes survive.
local function strip_path_wrappers(s)
if not s or s == '' then return s end
s = s:gsub('^[%(%[%{<"\'`]+', '')
s = s:gsub('([%)%]%}>"\'`]).*$', '')
s = s:gsub('[,;!%?]+$', '')
s = s:gsub('([^%.])%.$', '%1')
-- Drop a leading `./` or `.\` — purely presentational, but the rust scorer
-- otherwise can't recognise the path as an exact filename / path match.
s = s:gsub('^%./', '')
s = s:gsub('^%.\\', '')
return s
end
-- Split a `path:line:col` or `path:line` suffix off a path candidate.
-- Returns `(path, location|nil)`.
local function split_location_suffix(s)
if not s or s == '' then return s, nil end
local p, l, c = s:match('^(.-):(%d+):(%d+)$')
if p and p ~= '' then return p, { line = tonumber(l), col = tonumber(c) } end
local p2, l2 = s:match('^(.-):(%d+)$')
if p2 and p2 ~= '' then return p2, { line = tonumber(l2) } end
return s, nil
end
-- Heuristic: only a string with an explicit path separator (or `~`) is treated
-- as "definitely a path" worth resolving directly. Bare names like `foo.lua`
-- still go through the fuzzy picker so frecency / disambiguation can help.
local function looks_like_path(s)
if not s or s == '' then return false end
if vim.startswith(s, '~') then return true end
return s:find('[/\\]') ~= nil
end
-- Resolve `path` to an existing file on disk. Tries (in order): expanded
-- absolute, base_path-relative, cwd-relative. Returns the absolute path on
-- success, otherwise `nil`.
local function resolve_existing_file(path)
if not path or path == '' then return nil end
local expanded = vim.fn.expand(path)
-- Absolute (after ~ expansion): check directly
if vim.fn.fnamemodify(expanded, ':p') == expanded then
if vim.fn.filereadable(expanded) == 1 then return expanded end
return nil
end
local seen = {}
local function try(candidate)
if not candidate or seen[candidate] then return nil end
seen[candidate] = true
if vim.fn.filereadable(candidate) == 1 then return candidate end
return nil
end
local base = require('fff.conf').get().base_path
if base and base ~= '' then
local hit = try(vim.fs.normalize(base .. '/' .. expanded))
if hit then return hit end
end
return try(vim.fs.normalize(vim.fn.getcwd() .. '/' .. expanded))
end
-- Open `abs_path` honouring the same window-targeting dance as `M.select`:
-- if the current window is `winfixbuf` / has a special buftype, retarget to
-- a suitable window, else fall back to `:split`. Optionally jumps to a
-- `location = { line, col }` after the buffer loads.
local function open_resolved_file(abs_path, relative_path, location, open_cb)
local utils = require('fff.utils')
local cwd_relative = vim.fn.fnamemodify(abs_path, ':.')
if open_cb and type(open_cb) == 'function' then
local cb_ok, cb_err = pcall(open_cb, abs_path, relative_path or cwd_relative)
if not cb_ok then vim.notify('open_file_under_cursor open_cb error: ' .. tostring(cb_err), vim.log.levels.ERROR) end
end
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_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = current_buf })
local current_winfixbuf = utils.window_has_winfixbuf(current_win)
local opened_via_split = false
if current_buftype ~= '' or not current_modifiable or current_winfixbuf then
local suitable_win = utils.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(cwd_relative))
opened_via_split = true
end
end
if not opened_via_split then vim.cmd('edit ' .. vim.fn.fnameescape(cwd_relative)) end
if location then vim.schedule(function() require('fff.location_utils').jump_to_location(location) end) end
end
--- Try to open the file/path under the cursor.
---
--- Picks up the `<cWORD>` (whitespace-delimited token) from the current line,
--- strips wrapping punctuation (`[]`, `()`, quotes, trailing `,`/`.`/etc.),
--- and tries to open it. Resolution order:
---
--- 1. **Direct path**: if the cWORD looks like a path (has `/`, `\`, or
--- `~`) and resolves to a real file (absolute, or relative to the
--- picker's `base_path`, then to neovim's cwd), open it directly. A
--- `:line:col` suffix is parsed and the cursor jumps to that location.
--- This skips the fuzzy picker entirely — when the user has clearly
--- typed a path, we don't second-guess them.
--- 2. **Fuzzy match**: otherwise run a fuzzy search. If exactly one file
--- matches, or the top hit is an exact-path match, open it.
--- 3. **Picker UI fallback**: if the cWORD looks like a path but several
--- files match ambiguously, open the picker UI with the cWORD as a
--- starter query.
--- 4. **No-op**: if the cWORD is empty or matches nothing (and isn't a
--- resolvable path), do nothing — no surprise UI popup.
---
--- `:edit` is window-aware: if the current window has `winfixbuf` or a
--- special buftype, the file is opened in another suitable window or via
--- `:split`.
---
--- The optional `open_cb` is invoked **before** `:edit` runs with
--- `(absolute_path, relative_path)` — useful for plugins that want to mirror
--- the open into a side panel, log the access, etc.
--- @param open_cb fun(abs_path: string, relative_path: string)|nil
function M.open_file_under_cursor(open_cb)
local raw_word = vim.fn.expand('<cWORD>')
local query = strip_path_wrappers(raw_word)
if not query or query == '' then return end
-- Fast path: cWORD looks like a path AND resolves on disk → just open it.
-- This catches the common `gf`-on-`./file_picker.rs` case where the fuzzy
-- search would otherwise return many substring matches and pop the UI.
local path_part, location = split_location_suffix(query)
if looks_like_path(path_part) then
local resolved = resolve_existing_file(path_part)
if resolved then
open_resolved_file(resolved, path_part, location, open_cb)
return
end
end
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui.picker_ui')
if not picker_ok then
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
return
end
picker_ui.open_with_callback(query, function(files, _, fuzzy_location, get_file_score)
-- Empty results: don't pop up the picker UI on words that aren't paths.
if not files or #files == 0 then return true end
local first_score = get_file_score and get_file_score(1) or nil
local exact = first_score and first_score.exact_match or false
if #files ~= 1 and not exact then
-- Ambiguous: let the picker UI surface the candidates.
return false
end
local utils = require('fff.utils')
local item = files[1]
local abs_path = utils.canonicalize_fff_path(item.relative_path)
if not abs_path then return true end
open_resolved_file(abs_path, item.relative_path, fuzzy_location, open_cb)
return true
end)
end
return M
+82
View File
@@ -0,0 +1,82 @@
# `fff.picker_ui` — Picker UI Module
This directory contains the full implementation of the FFF file picker UI.
Originally a single monolithic ~2535-line file, it has been split into focused submodules.
## Entry point
**`picker_ui.lua`** — requires `require('fff.picker_ui.picker_ui')`.
This is the coordinator. It wires all submodules together and exposes the public API:
| Function | Purpose |
|---|---|
| `M.open(opts)` | Open the file picker |
| `M.open_with_callback(query, callback, opts)` | Search with a callback, fall back to opening the picker |
| `M.select(action)` | Open the selected file (edit/split/vsplit/tab) |
| `M.toggle_select()` | Toggle multi-selection for the current item |
| `M.send_to_quickfix()` | Send selected items to the quickfix list |
| `M.toggle_debug()` | Toggle debug score display |
| `M.monitor_scan_progress()` | Poll indexing progress during initial scan |
## Submodules
| Module | File | Purpose | Key exports via `picker_ui.lua` |
|---|---|---|---|
| **picker_ui_state** | `picker_ui_state.lua` | Single source of truth for all picker state | `M.state`, `M.clear_selections`, `M.reset_history_state` |
| **ui_creator** | `ui_creator.lua` | Creates buffers, windows, and keymaps for the picker UI | `M.create_ui`, `M.setup_buffers`, `M.setup_windows`, `M.setup_keymaps`, `M.focus_*_win`, `M.open_preview`, `M.close_preview` |
| **search_manager** | `search_manager.lua` | Executes searches, manages pagination, handles query history | `M.update_results_sync`, `M.update_results`, `M.load_*_page`, `M.on_input_change`, `M.cycle_grep_modes`, `M.recall_query_from_history`, `M.cycle_forward_query`, `M.get_suggestion_renderer` |
| **renderer** | `renderer.lua` | Renders the file list, handles combo separator, scrollbar, and empty state | `M.render_list`, `M.scroll_to_bottom` |
| **preview_manager** | `preview_manager.lua` | Manages file preview rendering, debounced updates, and preview title | `M.update_preview`, `M.update_preview_smart`, `M.update_preview_debounced`, `M.update_preview_title`, `M.clear_preview` |
| **navigation** | `navigation.lua` | Handles cursor movement, pagination wrap-around, and preview scrolling | `M.move_up`, `M.move_down`, `M.wrap_to_first`, `M.wrap_to_last`, `M.scroll_preview_up`, `M.scroll_preview_down` |
| **layout_manager** | `layout_manager.lua` | Recalculates layout on terminal resize (VimResized) and cleans up on close | `M.relayout`, `M.close` |
| **file_renderer** | `file_renderer.lua` | Renders individual file lines with icon, path, and score | `M.render_line`, `M.apply_highlights` |
| **grep_renderer** | `grep_renderer.lua` | Grep search execution and rendering of grep result lines | `M.search`, `M.get_search_metadata`, `M.render_line`, `M.apply_highlights` |
| **combo_renderer** | `combo_renderer.lua` | Detects combo (directory common prefix) items from a list | `M.detect` |
| **list_renderer** | `list_renderer.lua` | Renders the full file list with separator, scrollbar, and combo display | `M.render` |
| **utils** | `utils.lua` | Utility functions (quickfix list building) | `M.send_to_quickfix` |
### Module relationships
```
picker_ui.lua (coordinator)
├── ui_creator.lua — buffer/window/keymap creation
├── search_manager.lua → grep_renderer.lua → file_renderer.lua
├── renderer.lua → list_renderer.lua, combo_renderer.lua
│ ├─ file_renderer.lua
│ └─ list_separator (external)
├── preview_manager.lua
├── navigation.lua
├── layout_manager.lua
└── utils.lua → grep_renderer.lua (for exhaustive grep in send_to_quickfix)
```
## Architecture
All submodules follow the same pattern:
```lua
local M = {}
local P = nil -- parent module reference
function M.init(parent_module) P = parent_module end
local S = picker_ui_state.state -- shared state
-- ... functions that use S.* and P.* ...
return M
```
The coordinator (`picker_ui.lua`) calls `module.init(M)` on each submodule, passing itself as the parent. This lets submodules call back into `picker_ui.lua` for cross-module coordination — for example, `navigation.lua` calls `P.render_list()` and `P.update_preview()` after moving the cursor.
State is shared via a single table reference (`picker_ui_state.state`). Every submodule writes to and reads from the same table — no message passing or event bus.
Modules without `init()` are either pure data stores (`picker_ui_state`) or standalone utility modules without cross-module dependencies (`file_renderer`, `grep_renderer`, `combo_renderer`, `list_renderer`, `utils`).
## Key design decisions
- **`picker_ui_state` is a pure data store** — no parent module reference, no `init()`. It owns the state table and selection helpers.
- **`picker_ui.lua` keeps cross-cutting concerns** — `update_status`, `select`, `toggle_debug`, `open` lives here because they coordinate across multiple submodules.
- **Module purpose is reflected in the filename** — anything with `_manager` in the name manages state or lifecycle; `renderer` and `navigation` are purely behavioral; `*_renderer` modules handle rendering of specific item types.
- **`vim.schedule` usage is intentional** — deferred calls prevent re-entrancy issues during buffer mutations and window teardown.
+49
View File
@@ -0,0 +1,49 @@
--- Combo header policy.
---
--- Pure logic: detects whether the current result set has a "combo" boost,
--- and produces the label text for the list separator. The actual divider
--- is rendered by `list_separator` so this module owns no windows or buffers.
local M = {}
local COMBO_TEXT_FORMAT = 'Last Match (×%d combo)'
local LAST_MATCH_TEXT = 'Last Match'
--- @param items table[]
--- @param file_picker table
--- @param combo_boost_score_multiplier number
--- @return number|nil idx 1-based item index of the combo anchor, nil if none
--- @return number combo_count Multiplier (combo_match_boost / multiplier)
local function detect_combo_item(items, file_picker, combo_boost_score_multiplier)
if not items or #items == 0 then return nil, 0 end
local first_score = file_picker.get_file_score(1)
local last_score = file_picker.get_file_score(#items)
if first_score and first_score.combo_match_boost > combo_boost_score_multiplier then
return 1, first_score.combo_match_boost / combo_boost_score_multiplier
elseif last_score and last_score.combo_match_boost > combo_boost_score_multiplier then
return #items, last_score.combo_match_boost / combo_boost_score_multiplier
end
return nil, 0
end
--- @class FffComboInfo
--- @field idx number Item index of the combo anchor
--- @field text string Label text for the separator
--- @field count number Combo multiplier
--- @param items table[]
--- @param file_picker table
--- @param combo_boost_score_multiplier number
--- @param disable_combo_display boolean
--- @return FffComboInfo|nil
function M.detect(items, file_picker, combo_boost_score_multiplier, disable_combo_display)
local idx, count = detect_combo_item(items, file_picker, combo_boost_score_multiplier)
if not idx then return nil end
local text = disable_combo_display and LAST_MATCH_TEXT or string.format(COMBO_TEXT_FORMAT, count)
return { idx = idx, text = text, count = count }
end
return M
+217
View File
@@ -0,0 +1,217 @@
--- File Renderer
--- Simple renderer for file items with 2 functions: render_line and apply_highlights
local M = {}
--- File Item structure from Rust
--- @class FileItem
--- @field path string Absolute file path
--- @field relative_path string Relative file path from base directory
--- @field name string File name
--- @field extension string File extension
--- @field size number File size in bytes
--- @field modified number Last modified timestamp
--- @field total_frecency_score number Total frecency score
--- @field access_frecency_score number Access-based frecency score
--- @field modification_frecency_score number Modification-based frecency score
--- @field git_status string|nil Git status string (e.g. 'modified', 'untracked') if file is in git repo
--- Render a file item line
--- @param item FileItem File item from Rust
--- @param ctx ListRenderContext Render context with all state
--- @param item_idx number|nil 1-based item index in ctx.items
--- @return string[] Array of line strings (always exactly 1)
function M.render_line(item, ctx, item_idx) -- luacheck: ignore item_idx
local icons = require('fff.file_picker.icons')
local lines = {}
local icon, _ = icons.get_icon(item.name, item.extension, false)
-- Build frecency indicator (debug mode only)
local frecency = ''
if ctx.debug_enabled then
local total = item.total_frecency_score or 0
local access = item.access_frecency_score or 0
local mod = item.modification_frecency_score or 0
if total > 0 then
local indicator = ''
if mod >= 6 then
indicator = '🔥'
elseif access >= 4 then
indicator = '⭐️'
elseif total >= 3 then
indicator = ''
elseif total >= 1 then
indicator = ''
end
frecency = string.format(' %s%d', indicator, total)
end
end
-- Format filename and path
-- Don't reserve space for frecency - path takes priority
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
local available_width = math.max(ctx.max_path_width - icon_width, 40)
local filename, dir_path = ctx.format_file_display(item, available_width)
-- Build line
local line = icon and string.format('%s %s %s%s', icon, filename, dir_path, frecency)
or string.format('%s %s%s', filename, dir_path, frecency)
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
table.insert(lines, line .. string.rep(' ', padding))
return lines
end
--- Apply highlights to a rendered line
--- @param item FileItem File item from Rust
--- @param ctx ListRenderContext Render context with all state
--- @param item_idx number Item index (1-based)
--- @param buf number Buffer handle
--- @param ns_id number Namespace ID
--- @param line_idx number 1-based line index in buffer
--- @param line_content string The actual line content
function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_content)
local icons = require('fff.file_picker.icons')
local highlights = require('fff.highlights')
local file_picker = require('fff.file_picker')
local is_cursor = (ctx.cursor == item_idx)
local score = file_picker.get_file_score(item_idx)
local is_current_file = score and score.current_file_penalty and score.current_file_penalty < 0
-- Get icon and paths
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
local available_width = math.max(ctx.max_path_width - icon_width, 40)
local filename, dir_path = ctx.format_file_display(item, available_width)
-- 1. Cursor highlight
if is_cursor then
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
end_col = 0,
end_row = line_idx,
hl_group = ctx.config.hl.cursor,
hl_eol = true,
priority = 100,
})
end
-- 2. Icon
if icon and icon_hl_group and vim.fn.strdisplaywidth(icon) > 0 then
local icon_hl = is_current_file and 'Comment' or icon_hl_group
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
line_idx - 1,
0,
{ end_col = vim.fn.strdisplaywidth(icon), hl_group = icon_hl }
)
end
-- 3. Git text color (filename)
if ctx.config.git and ctx.config.git.status_text_color and icon and #filename > 0 then
local git_text_hl = item.git_status and highlights.get_git_text_highlight(item.git_status) or nil
if git_text_hl and git_text_hl ~= '' and not is_current_file then
local filename_start = #icon + 1
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
line_idx - 1,
filename_start,
{ end_col = filename_start + #filename, hl_group = git_text_hl }
)
end
end
-- 4. Frecency indicator
if ctx.debug_enabled then
local start_pos, end_pos = line_content:find('[⭐️🔥✨•]%d+')
if start_pos and end_pos then
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
line_idx - 1,
start_pos - 1,
{ end_col = end_pos, hl_group = ctx.config.hl.frecency }
)
end
end
-- 5. Directory path (dimmed)
if #filename > 0 and #dir_path > 0 then
local prefix_len = #filename + 1 -- filename bytes + space
if icon then
prefix_len = prefix_len + #icon + 1 -- if icon add icon bytes + space
end
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
line_idx - 1,
prefix_len,
{ end_col = prefix_len + #dir_path, hl_group = ctx.config.hl.directory_path }
)
end
-- 6. Current file
if is_current_file then
local hl
if is_cursor then
hl = ctx.config.hl.cursor
else
hl = 'Comment'
end
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
virt_text = { { ' ' .. ctx.config.file_picker.current_file_label, hl } },
virt_text_pos = 'right_align',
})
end
-- 7. Git sign
if item.git_status and highlights.should_show_git_border(item.git_status) then
local border_char = highlights.get_git_border_char(item.git_status)
local border_hl = highlights.get_git_sign_highlight(item.git_status, is_cursor, ctx.config.hl.cursor)
if border_hl and border_hl ~= '' then
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
sign_text = border_char,
sign_hl_group = border_hl,
priority = 1000,
})
end
elseif is_cursor then
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
sign_text = ' ',
sign_hl_group = ctx.config.hl.cursor,
priority = 1000,
})
end
-- 8. Selection
if ctx.selected_files and ctx.selected_files[item.relative_path] then
local selection_hl = is_cursor and ctx.config.hl.selected_active or ctx.config.hl.selected
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
sign_text = '',
sign_hl_group = selection_hl,
priority = 1001,
})
end
-- 9. Query match
if ctx.query and ctx.query ~= '' then
local match_start, match_end = string.find(line_content, ctx.query, 1, true)
if match_start and match_end then
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
line_idx - 1,
match_start - 1,
{ end_col = match_end, hl_group = ctx.config.hl.matched or 'IncSearch' }
)
end
end
end
return M
+297
View File
@@ -0,0 +1,297 @@
--- Grep search bridge and renderer.
--- Wraps the Rust `live_grep` FFI function with file-based pagination state tracking.
--- Also provides renderer for live grep results with file grouping.
local M = {
supports_cursor_rerender = true,
}
local fuzzy = require('fff.fuzzy')
local file_renderer = require('fff.picker_ui.file_renderer')
local tresitter_highlight = require('fff.treesitter_hl')
-- ===== Search Bridge =====
---@class fff.grep.SearchResult
---@field items table[] Array of grep match items
---@field total_matched number Total matches found in this call
---@field total_files_searched number Files actually searched in this call
---@field total_files number Total indexed files
---@field filtered_file_count number Total searchable files after filtering
---@field next_file_offset number File offset to pass for the next page (0 = no more results)
---@field regex_fallback_error string|nil Error message if regex compilation failed and search fell back to literal
local last_result = nil
--- Perform a grep search.
---@param query string The search query (may contain file constraints like *.rs)
---@param file_offset? number Index into sorted file list to start from (default 0)
---@param page_size? number Max matches to collect (default 50)
---@param config? table Grep configuration overrides
---@param grep_mode? string Search mode: "plain" (default), "regex", or "fuzzy"
---@return fff.grep.SearchResult
function M.search(query, file_offset, page_size, config, grep_mode)
local conf = config or {}
last_result = fuzzy.live_grep(
query or '',
file_offset or 0,
page_size or 50,
conf.max_file_size,
conf.max_matches_per_file,
conf.smart_case,
grep_mode or 'plain',
conf.time_budget_ms,
conf.trim_whitespace
)
return last_result
end
--- Get metadata from the last search result.
---@return { total_matched: number, total_files_searched: number, total_files: number, next_file_offset: number }
function M.get_search_metadata()
if not last_result then
return { total_matched = 0, total_files_searched = 0, total_files = 0, next_file_offset = 0 }
end
return {
total_matched = last_result.total_matched or 0,
total_files_searched = last_result.total_files_searched or 0,
total_files = last_result.total_files or 0,
next_file_offset = last_result.next_file_offset or 0,
}
end
-- ===== Renderer =====
--- Build the file group header line using the same layout as file_renderer.
--- Delegates to file_renderer.render_line (with combo disabled).
---@param item FileItem Grep match
---@param ctx table Render context
---@return string The header line string
local function build_group_header(item, ctx)
local lines = file_renderer.render_line(item, ctx)
return lines[1]
end
--- Apply highlights for a file group header line using file_renderer.
---@param item FileItem Grep match item
---@param ctx ListRenderContext Render context
---@param buf number Buffer handle
---@param ns_id number Namespace id
---@param row number 0-based row in buffer (header line)
local function apply_group_header_highlights(item, ctx, buf, ns_id, row)
local line_content = vim.api.nvim_buf_get_lines(buf, row, row + 1, false)[1] or ''
local saved_cursor = ctx.cursor
ctx.cursor = -1
file_renderer.apply_highlights(item, ctx, 0, buf, ns_id, row + 1, line_content)
ctx.cursor = saved_cursor
end
--- Format a grep match location string.
---@param item table Grep match item
---@param ctx table Render context
---@return string
local function format_location(item, ctx)
local fmt = (ctx.config and ctx.config.grep and ctx.config.grep.location_format) or ':%d:%d'
local ok, str = pcall(string.format, fmt, item.line_number or 0, (item.col or 0) + 1)
if not ok then str = string.format(':%d:%d', item.line_number or 0, (item.col or 0) + 1) end
return str
end
local BINARY_PLACEHOLDER = '<binary content>'
--- Render a single grep match line.
---@param item table Grep match item
---@param ctx table Render context
---@return string
local function render_match_line(item, ctx)
local location = format_location(item, ctx)
local separator = ' '
local raw_content = item.line_content
if type(raw_content) ~= 'string' then raw_content = raw_content and tostring(raw_content) or '' end
local content = raw_content
if item.is_binary_content then content = BINARY_PLACEHOLDER end
local indent = ' '
local prefix_display_w = #indent + #location + #separator
local available = ctx.win_width - prefix_display_w - 2
local content_display_w = vim.fn.strdisplaywidth(content)
if content_display_w > available and available > 3 then
local nchars = vim.fn.strchars(content)
local lo, hi = 0, nchars
while lo < hi do
local mid = math.floor((lo + hi + 1) / 2)
if vim.fn.strdisplaywidth(vim.fn.strcharpart(content, 0, mid)) <= available - 1 then
lo = mid
else
hi = mid - 1
end
end
content = vim.fn.strcharpart(content, 0, lo) .. ''
end
local line = indent .. location .. separator .. content
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
item._match_indent = #indent
item._content_offset = prefix_display_w
item._trimmed_content = content
return line .. string.rep(' ', padding)
end
--- Apply highlights for a grouped match line.
---@param item table Grep match item
---@param item_idx number 1-based item index
---@param buf number Buffer handle
---@param ns_id number Namespace id
---@param row number 0-based row in buffer
---@param line_content string The rendered line text
local function apply_match_highlights(item, item_idx, buf, ns_id, row, line_content, ctx)
local config = ctx.config
local is_cursor = item_idx == ctx.cursor
local indent = item._match_indent or 1
if is_cursor then
vim.api.nvim_buf_set_extmark(buf, ns_id, row, 0, {
end_col = 0,
end_row = row + 1,
hl_group = config.hl.cursor,
hl_eol = true,
priority = 100,
})
end
local location_str = format_location(item, ctx)
local loc_start = indent
local loc_end = loc_start + #location_str
if loc_end <= #line_content then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, loc_start, {
end_col = loc_end,
hl_group = config.hl.grep_line_number or 'LineNr',
priority = 150,
})
end
local sep_start = loc_end
local sep_end = sep_start + 2
if sep_end <= #line_content then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, sep_start, {
end_col = sep_end,
hl_group = 'Comment',
priority = 150,
})
end
local content_start = sep_end
if item.is_binary_content then
local content_end = content_start + #BINARY_PLACEHOLDER
if content_end <= #line_content then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, content_start, {
end_col = content_end,
hl_group = 'Comment',
priority = 150,
})
end
elseif item._trimmed_content and item.name then
ctx._ts_lang_cache = ctx._ts_lang_cache or {}
local lang = ctx._ts_lang_cache[item.name]
if lang == nil then
lang = tresitter_highlight.lang_from_filename(item.name) or false
ctx._ts_lang_cache[item.name] = lang
end
if lang then
local highlights = tresitter_highlight.get_line_highlights(item._trimmed_content, lang)
for _, hl in ipairs(highlights) do
local hl_start = content_start + hl.col
local hl_end = content_start + hl.end_col
if hl_start < #line_content and hl_end <= #line_content then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, hl_start, {
end_col = hl_end,
hl_group = hl.hl_group,
priority = 120,
})
end
end
end
end
if item.match_ranges and not item.is_binary_content then
for _, range in ipairs(item.match_ranges) do
local raw_start = range[1] or 0
local raw_end = range[2] or 0
if raw_end > 0 then
raw_start = math.max(0, raw_start)
local hl_start = content_start + raw_start
local hl_end = content_start + raw_end
if hl_start < #line_content and hl_end <= #line_content then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, hl_start, {
end_col = hl_end,
hl_group = config.hl.grep_match or 'IncSearch',
priority = 200,
})
end
end
end
end
if ctx.selected_items then
local key = string.format('%s:%d:%d', item.relative_path, item.line_number or 0, item.col or 0)
if ctx.selected_items[key] then
vim.api.nvim_buf_set_extmark(buf, ns_id, row, 0, {
sign_text = '',
sign_hl_group = config.hl.selected or 'FFFSelected',
priority = 1001,
})
end
end
end
--- Render a single item's lines (called by list_renderer).
--- Returns 2 lines [header, match] for the first match of a file group,
--- or 1 line [match] for subsequent matches in the same file.
---@param item FileItem Grep match item
---@param ctx table Render context
---@param item_idx number 1-based item index in ctx.items
---@return string[]
function M.render_line(item, ctx, item_idx)
-- First rendered item in this pass always gets header — fixes missing header
-- when paginating backward in multi-page grep results (ctx is fresh per render).
local is_first_visible = (item_idx == ctx.iter_start)
local is_new_group = is_first_visible or (item.relative_path ~= ctx.grep_last_file)
ctx.grep_last_file = item.relative_path
local match_line = render_match_line(item, ctx)
if is_new_group then
---@diagnostic disable-next-line: inject-field
item._has_group_header = true
local header_line = build_group_header(item, ctx)
return { header_line, match_line }
else
---@diagnostic disable-next-line: inject-field
item._has_group_header = false
return { match_line }
end
end
--- Apply highlights for rendered lines (called by list_renderer).
---@param item FileItem Grep match item
---@param ctx ListRenderContext Render context
---@param item_idx number 1-based item index
---@param buf number Buffer handle
---@param ns_id number Namespace id
---@param line_idx number 1-based line index of the match line
---@param line_content string The rendered match line text
function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_content)
local row = line_idx - 1
apply_match_highlights(item, item_idx, buf, ns_id, row, line_content, ctx)
---@diagnostic disable-next-line: undefined-field
if item._has_group_header then apply_group_header_highlights(item, ctx, buf, ns_id, row - 1) end
end
return M
+150
View File
@@ -0,0 +1,150 @@
local M = {}
local conf = require('fff.conf')
local layout = require('fff.layout')
local list_separator = require('fff.list_separator')
local scrollbar = require('fff.scrollbar')
local preview = require('fff.file_picker.preview')
local picker_ui_state = require('fff.picker_ui.picker_ui_state')
-- Parent module reference (set by picker_ui.lua during initialization).
---@type table
local P = nil
function M.init(parent_module) P = parent_module end
local S = picker_ui_state.state
local function restore_paste(should_restore)
if should_restore then vim.o.paste = true end
end
function M.relayout()
if not P.state.active then return end
local config = S.config
if not config then return end
local computed_layout = layout.compute(config, conf.preview_enabled(config))
local win_configs = computed_layout.win_configs
S.layout = computed_layout.layout
S.preview_visible = computed_layout.preview_visible
if S.list_win and vim.api.nvim_win_is_valid(S.list_win) then
vim.api.nvim_win_set_config(S.list_win, win_configs.list)
end
if S.input_win and vim.api.nvim_win_is_valid(S.input_win) then
vim.api.nvim_win_set_config(S.input_win, win_configs.input)
end
local preview_win_alive = S.preview_win and vim.api.nvim_win_is_valid(S.preview_win)
if S.preview_visible and win_configs.preview then
if preview_win_alive then
vim.api.nvim_win_set_config(S.preview_win, win_configs.preview)
else
P.open_preview(win_configs.preview)
end
elseif preview_win_alive then
P.close_preview()
end
local file_info_win_alive = S.file_info_win and vim.api.nvim_win_is_valid(S.file_info_win)
if win_configs.file_info then
if file_info_win_alive then
vim.api.nvim_win_set_config(S.file_info_win, win_configs.file_info)
else
S.file_info_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = S.file_info_buf })
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = S.file_info_buf })
vim.api.nvim_set_option_value('filetype', 'fff_file_info', { buf = S.file_info_buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = S.file_info_buf })
S.file_info_win = vim.api.nvim_open_win(S.file_info_buf, false, win_configs.file_info)
end
elseif file_info_win_alive then
vim.api.nvim_win_close(S.file_info_win, true)
S.file_info_win = nil
if S.file_info_buf and vim.api.nvim_buf_is_valid(S.file_info_buf) then
vim.api.nvim_buf_delete(S.file_info_buf, { force = true })
end
S.file_info_buf = nil
end
P.render_list()
P.update_preview()
P.update_status()
end
function M.close()
if not P.state.active then return end
vim.cmd('stopinsert')
P.state.active = false
restore_paste(S.restore_paste)
list_separator.cleanup()
scrollbar.cleanup()
local ts_ok, ts_hl = pcall(require, 'fff.treesitter_hl')
if ts_ok then ts_hl.cleanup() end
local windows = { S.input_win, S.list_win, S.preview_win }
if S.file_info_win then table.insert(windows, S.file_info_win) end
for _, win in ipairs(windows) do
if win and vim.api.nvim_win_is_valid(win) then vim.api.nvim_win_close(win, true) end
end
local buffers = { S.input_buf, S.list_buf, S.file_info_buf }
if S.preview_buf then buffers[#buffers + 1] = S.preview_buf end
for _, buf in ipairs(buffers) do
if buf and vim.api.nvim_buf_is_valid(buf) then
vim.api.nvim_buf_clear_namespace(buf, -1, 0, -1)
if buf == S.preview_buf then preview.clear_buffer(buf) end
vim.api.nvim_buf_delete(buf, { force = true })
end
end
P.close_preview_timer()
S.input_win = nil
S.list_win = nil
S.file_info_win = nil
S.preview_win = nil
S.input_buf = nil
S.list_buf = nil
S.file_info_buf = nil
S.preview_buf = nil
S.preview_visible = false
S.items = {}
S.filtered_items = {}
S.line_to_item = {}
S.item_to_lines = {}
S.last_render_ctx = nil
S.cursor = 1
S.query = ''
S.ns_id = nil
S.last_preview_file = nil
S.last_preview_location = nil
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
S.grep_mode = 'plain'
S.grep_regex_fallback_error = nil
S.suggestion_items = nil
S.suggestion_source = nil
S.renderer = nil
S.restore_paste = false
S.combo_visible = true
S.combo_initial_cursor = nil
P.reset_history_state()
pcall(vim.api.nvim_del_augroup_by_name, 'fff_picker_focus')
end
return M
+258
View File
@@ -0,0 +1,258 @@
--- List Renderer, renders any type of list results produced by fff
local M = {}
--- @class ListRenderContext
--- @field config FffConfig User configuration
--- @field items table[] Array of data items to render
--- @field cursor number Current cursor position (1-based index into items)
--- @field win_height number Window height in lines
--- @field win_width number Window width in columns
--- @field max_path_width number Actual text area width (excluding signcolumn)
--- @field debug_enabled boolean Whether debug mode shows scores
--- @field prompt_position string 'top' or 'bottom'
--- @field has_combo boolean Whether combo boost is active
--- @field display_start number Start index for displayed items (1)
--- @field display_end number End index for displayed items (#items)
--- @field iter_start number Iteration start
--- @field iter_end number Iteration end
--- @field iter_step number Iteration step (1 or -1)
--- @field renderer table|nil Custom renderer with render_line/apply_highlights
--- @field query string Current search query
--- @field selected_files table<string, boolean> Selected file paths set
--- @field mode string|nil Current mode (nil or 'grep')
--- @field format_file_display function Helper for formatting file display
--- @field suggestion_source string|nil Active cross-mode suggestion source ('grep' or 'files')
--- @class ItemLineMapping
--- @field first number First buffer line (1-based) this item occupies
--- @field last number Last buffer line (1-based) — the selectable content line
--- @field virtual_count number Number of virtual (header) lines before the content line
--- @class ListRenderResult
--- @field lines string[] All buffer lines (including virtual rows and padding)
--- @field item_to_lines table<number, ItemLineMapping> Maps item index -> line range
--- @field padding_offset number Number of empty lines prepended for bottom prompt
--- @field total_content_lines number Lines before padding was applied
--- @field separator_line number|nil Buffer line (1-based) where separator sits, nil if none
--- @class ListSeparator
--- @field idx number 1-based item index — separator visually sits between this item and the previous one in the iteration order
--- @field text string Label text rendered inside the separator
--- @field text_hl string|nil Highlight group for label
--- @field border_hl string|nil Highlight group for the dashes
--- Generate all display lines from items from the renderer ctx
--- @param ctx table
--- @return string[] lines Array of line strings
--- @return table<number, ItemLineMapping> item_to_lines
--- @return number|nil separator_line 1-based buffer line of the separator, nil if none
local function generate_item_lines(ctx)
local lines = {}
local item_to_lines = {}
local separator_line = nil
-- Cross-mode suggestion header: rendered above items visually.
-- For top prompt that means before items; for bottom prompt after items
-- (because bottom prompt iterates in reverse).
local suggestion_header_lines = {}
local has_suggestion_header = ctx.suggestion_source ~= nil and #ctx.items > 0
if has_suggestion_header then
table.insert(suggestion_header_lines, '')
if ctx.mode == 'grep' and ctx.suggestion_source == 'files' then
-- Grep mode with no results — hint about mode cycling to fuzzy search
local config = require('fff.conf').get()
local keybind = config.keymaps.cycle_grep_modes
if type(keybind) == 'table' then keybind = keybind[1] or '<S-Tab>' end
table.insert(suggestion_header_lines, ' No results, try ' .. keybind .. ' to fuzzy search')
else
local mode_label = ctx.suggestion_source == 'grep' and 'content matches' or 'file name matches'
table.insert(suggestion_header_lines, ' No results found. Suggested ' .. mode_label .. ':')
end
table.insert(suggestion_header_lines, '')
end
-- For top prompt: suggestion header goes before items
if has_suggestion_header and ctx.prompt_position ~= 'bottom' then
for _, hline in ipairs(suggestion_header_lines) do
table.insert(lines, hline)
end
end
local renderer = ctx.renderer
if not renderer then renderer = require('fff.picker_ui.file_renderer') end
-- Insert a gap on the side of the anchor away from the prompt.
-- Bottom prompt: gap BEFORE anchor in iter — anchor renders last with
-- reverse iter, so gap lands just above anchor visually.
-- Top prompt: gap AFTER anchor in iter — anchor renders first with
-- forward iter, so gap lands just below anchor visually.
-- Either way: anchor stays adjacent to the prompt, separator on far side.
local gap_before_anchor = ctx.prompt_position == 'bottom'
for i = ctx.iter_start, ctx.iter_end, ctx.iter_step do
if ctx.separator and ctx.separator.idx == i and gap_before_anchor then
table.insert(lines, '')
separator_line = #lines
end
local item = ctx.items[i]
local item_start_line = #lines + 1
local item_lines = renderer.render_line(item, ctx, i)
vim.list_extend(lines, item_lines)
local item_end_line = #lines
local virtual_count = item_end_line - item_start_line
item_to_lines[i] = {
first = item_start_line,
last = item_end_line,
virtual_count = virtual_count,
}
if ctx.separator and ctx.separator.idx == i and not gap_before_anchor then
table.insert(lines, '')
separator_line = #lines
end
end
-- For bottom prompt: suggestion header goes after items (appears above visually)
if has_suggestion_header and ctx.prompt_position == 'bottom' then
for _, hline in ipairs(suggestion_header_lines) do
table.insert(lines, hline)
end
end
return lines, item_to_lines, separator_line
end
--- Apply bottom padding: prepend empty lines so content sits at the bottom.
--- Adjusts all line indices in item_to_lines accordingly.
--- @param lines string[] Lines array (mutated)
--- @param item_to_lines table<number, ItemLineMapping> Mapping (mutated)
--- @param ctx table
--- @param separator_line number|nil Pre-padding separator line (mutated via return value)
--- @return number padding_offset Number of empty lines prepended
--- @return number|nil shifted_separator_line
local function apply_bottom_padding(lines, item_to_lines, ctx, separator_line)
if ctx.prompt_position ~= 'bottom' then return 0, separator_line end
local total_content_lines = #lines
local empty_lines_needed = math.max(0, ctx.win_height - total_content_lines)
if empty_lines_needed > 0 then
for _ = empty_lines_needed, 1, -1 do
table.insert(lines, 1, string.rep(' ', ctx.win_width + 5))
end
for i = ctx.display_start, ctx.display_end do
if item_to_lines[i] then
item_to_lines[i].first = item_to_lines[i].first + empty_lines_needed
item_to_lines[i].last = item_to_lines[i].last + empty_lines_needed
end
end
if separator_line then separator_line = separator_line + empty_lines_needed end
end
return empty_lines_needed, separator_line
end
--- Write lines to the buffer and position the cursor on the correct line.
--- The cursor always targets the content line (last) of the current item,
--- never a virtual header line.
--- @param lines string[]
--- @param item_to_lines table<number, ItemLineMapping>
--- @param ctx table
--- @param list_buf number Buffer handle
--- @param list_win number Window handle
--- @param ns_id number Namespace id
local function update_buffer_and_cursor(lines, item_to_lines, ctx, list_buf, list_win, ns_id)
-- Resolve cursor to a buffer line — always the content line (last), not virtual rows
local cursor_line = 0
if #ctx.items > 0 and ctx.cursor >= 1 and ctx.cursor <= #ctx.items then
local cursor_item = item_to_lines[ctx.cursor]
if cursor_item then cursor_line = cursor_item.last end
end
vim.api.nvim_set_option_value('modifiable', true, { buf = list_buf })
vim.api.nvim_buf_set_lines(list_buf, 0, -1, false, lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = list_buf })
vim.api.nvim_buf_clear_namespace(list_buf, ns_id, 0, -1)
if #ctx.items > 0 and cursor_line > 0 and cursor_line <= #lines then
vim.api.nvim_win_set_cursor(list_win, { cursor_line, 0 })
end
end
--- Apply highlights for all items using the renderer's apply_highlights.
--- For each item, we pass the content line (last) to the renderer.
--- Renderers that emit virtual rows (grep_renderer) handle their own
--- header highlights internally via the item._has_group_header flag.
--- @param lines string[]
--- @param item_to_lines table<number, ItemLineMapping>
--- @param ctx table
--- @param list_buf number
--- @param ns_id number
local function apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id)
local renderer = ctx.renderer
if not renderer then renderer = require('fff.picker_ui.file_renderer') end
for i = ctx.display_start, ctx.display_end do
local item = ctx.items[i]
local item_lines = item_to_lines[i]
if item_lines then
-- The content line is always the last line in the mapping
local line_idx = item_lines.last
local line_content = lines[line_idx]
if line_content then
---@diagnostic disable-next-line: param-type-mismatch
renderer.apply_highlights(item, ctx, i, list_buf, ns_id, line_idx, line_content)
end
end
end
end
--- Render the full item list into the buffer.
--- This is the main entry point — replaces the inline rendering in picker_ui.
---
--- @param ctx table Render context built by picker_ui
--- @param list_buf number List buffer handle
--- @param list_win number List window handle
--- @param ns_id number Highlight namespace
--- @return table<number, ItemLineMapping> item_to_lines
--- @return number|nil separator_line 1-based buffer line of the separator (post-padding), nil if none
function M.render(ctx, list_buf, list_win, ns_id)
local lines, item_to_lines, separator_line = generate_item_lines(ctx)
local _, shifted_separator_line = apply_bottom_padding(lines, item_to_lines, ctx, separator_line)
separator_line = shifted_separator_line
update_buffer_and_cursor(lines, item_to_lines, ctx, list_buf, list_win, ns_id)
if #ctx.items > 0 then apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id) end
-- Highlight the suggestion header lines (if present)
if ctx.suggestion_source and #ctx.items > 0 then
local suggestion_hl = ctx.config.hl.suggestion_header or 'WarningMsg'
for i = 0, #lines - 1 do
local line = lines[i + 1]
if line and (line:match('^%s+No results found') or line:match('^%s+No results,')) then
pcall(
vim.api.nvim_buf_set_extmark,
list_buf,
ns_id,
i,
0,
{ end_row = i + 1, end_col = 0, hl_group = suggestion_hl }
)
end
end
end
return item_to_lines, separator_line
end
return M
+289
View File
@@ -0,0 +1,289 @@
local M = {}
local list_separator = require('fff.list_separator')
local preview = require('fff.file_picker.preview')
local layout = require('fff.layout')
local picker_ui_state = require('fff.picker_ui.picker_ui_state')
-- Parent module reference (set by picker_ui.lua during initialization).
---@type table
local P = nil
function M.init(parent_module) P = parent_module end
local S = picker_ui_state.state
local function get_prompt_position() return layout.resolve_prompt_position(S.config) end
--- After cursor moves, decide whether the combo separator should hide.
local function maybe_hide_combo_separator()
if not (S.combo_initial_cursor and S.combo_visible) then return end
local distance_past = S.cursor - S.combo_initial_cursor
if distance_past == 0 then return end
local half_page = math.floor(S.pagination.page_size * 0.5)
if math.abs(distance_past) <= half_page then return end
S.combo_visible = false
list_separator.hide()
P.render_list()
if get_prompt_position() == 'bottom' then P.scroll_to_bottom() end
end
function M.wrap_to_first()
if S.pagination.page_index == 0 then
S.cursor = 1
return true
end
if S.mode ~= 'grep' then
return P.load_page_at_index(0, function() S.cursor = 1 end)
end
if S.pagination.grep_file_offsets[1] ~= nil then
return P.load_page_at_index(0, function() S.cursor = 1 end)
end
S.cursor = 1
return true
end
function M.wrap_to_last()
local page_size = S.pagination.page_size
if page_size == 0 then return false end
if S.mode ~= 'grep' then
local total = S.pagination.total_matched
if total == 0 then return false end
local max_page_index = math.max(0, math.ceil(total / page_size) - 1)
if S.pagination.page_index == max_page_index then
S.cursor = #S.filtered_items
return true
end
return P.load_page_at_index(max_page_index, function(result_count) S.cursor = result_count end)
end
S.cursor = #S.filtered_items
return true
end
function M.move_up()
if not P.state.active then return end
if #S.filtered_items == 0 then return end
local prompt_position = get_prompt_position()
local items_count = #S.filtered_items
local old_cursor = S.cursor
local wrap_around = S.config and S.config.wrap_around or false
if prompt_position == 'bottom' then
local near_bottom = S.cursor >= (items_count - S.pagination.prefetch_margin)
local at_last_item = S.cursor >= items_count
if near_bottom and at_last_item then
local page_size = S.pagination.page_size
local has_more = false
if page_size > 0 then
if S.mode == 'grep' then
has_more = S.pagination.grep_next_file_offset > 0
else
local max_page = math.max(0, math.ceil(S.pagination.total_matched / page_size) - 1)
has_more = S.pagination.page_index < max_page
end
end
if has_more then
P.load_next_page()
return
elseif wrap_around then
M.wrap_to_first()
end
else
S.cursor = math.min(S.cursor + 1, items_count)
end
else
if S.cursor <= S.pagination.prefetch_margin + 1 and S.cursor <= 1 then
if S.pagination.page_index > 0 then
vim.schedule(P.load_previous_page)
return
elseif wrap_around then
M.wrap_to_last()
end
else
S.cursor = math.max(S.cursor - 1, 1)
end
end
if not P.render_after_cursor_move(old_cursor) then return end
P.update_status()
pcall(vim.cmd, 'redraw')
P.update_preview_debounced()
maybe_hide_combo_separator()
end
function M.move_down()
if not P.state.active then return end
if #S.filtered_items == 0 then return end
local prompt_position = get_prompt_position()
local items_count = #S.filtered_items
local old_cursor = S.cursor
local wrap_around = S.config and S.config.wrap_around or false
if prompt_position == 'bottom' then
if S.cursor <= S.pagination.prefetch_margin + 1 and S.cursor <= 1 then
if S.pagination.page_index > 0 then
vim.schedule(P.load_previous_page)
return
elseif wrap_around then
M.wrap_to_last()
end
else
S.cursor = math.max(S.cursor - 1, 1)
end
else
local near_bottom = S.cursor >= (items_count - S.pagination.prefetch_margin)
local at_last_item = S.cursor >= items_count
if near_bottom and at_last_item then
local page_size = S.pagination.page_size
local has_more = false
if page_size > 0 then
if S.mode == 'grep' then
has_more = S.pagination.grep_next_file_offset > 0
else
local max_page = math.max(0, math.ceil(S.pagination.total_matched / page_size) - 1)
has_more = S.pagination.page_index < max_page
end
end
if has_more then
P.load_next_page()
return
elseif wrap_around then
M.wrap_to_first()
end
else
S.cursor = math.min(S.cursor + 1, items_count)
end
end
if not P.render_after_cursor_move(old_cursor) then return end
P.update_status()
pcall(vim.cmd, 'redraw')
P.update_preview_debounced()
maybe_hide_combo_separator()
end
function M.scroll_preview_up()
if not P.state.active or not S.preview_win then return end
local win_height = vim.api.nvim_win_get_height(S.preview_win)
local scroll_lines = math.floor(win_height / 2)
preview.scroll(-scroll_lines)
end
function M.scroll_preview_down()
if not P.state.active or not S.preview_win then return end
local win_height = vim.api.nvim_win_get_height(S.preview_win)
local scroll_lines = math.floor(win_height / 2)
preview.scroll(scroll_lines)
end
-- Helper function to eliminate UI update redundancy
local function update_ui_after_jump(old_cursor)
if not P.render_after_cursor_move(old_cursor) then return false end
P.update_status()
pcall(vim.cmd, 'redraw')
P.update_preview_debounced()
return true
end
function M.grep_jump_to_next_file()
if not P.state.active or S.mode ~= 'grep' then return end
local items = S.filtered_items
if not items or #items == 0 then return end
local old_cursor = S.cursor
local current_path = items[S.cursor] and items[S.cursor].relative_path
for i = S.cursor + 1, #items do
if items[i].relative_path ~= current_path then
S.cursor = i
update_ui_after_jump(old_cursor)
return
end
end
if P.load_next_page and P.load_next_page() then
local new_items = S.filtered_items
if new_items and #new_items > 0 then
local idx = 1
if new_items[1].relative_path == current_path then
for i = 2, #new_items do
if new_items[i].relative_path ~= current_path then
idx = i
break
end
end
end
S.cursor = idx
update_ui_after_jump(old_cursor)
end
end
end
function M.grep_jump_to_prev_file()
if not P.state.active or S.mode ~= 'grep' then return end
local items = S.filtered_items
if not items or #items == 0 then return end
local old_cursor = S.cursor
local current_path = items[S.cursor] and items[S.cursor].relative_path
for i = S.cursor - 1, 1, -1 do
if items[i].relative_path ~= current_path then
local target_idx = i
while target_idx > 1 and items[target_idx - 1].relative_path == items[i].relative_path do
target_idx = target_idx - 1
end
S.cursor = target_idx
update_ui_after_jump(old_cursor)
return
end
end
if P.load_previous_page and P.load_previous_page() then
local new_items = S.filtered_items
if not new_items or #new_items == 0 then return end
local target_path = nil
for i = #new_items, 1, -1 do
if new_items[i].relative_path ~= current_path then
target_path = new_items[i].relative_path
break
end
end
target_path = target_path or new_items[#new_items].relative_path
local first = 1
for i = 1, #new_items do
if new_items[i].relative_path == target_path then
first = i
break
end
end
S.cursor = first
update_ui_after_jump(old_cursor)
end
end
return M
+778
View File
@@ -0,0 +1,778 @@
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 '<S-Tab>' 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
+260
View File
@@ -0,0 +1,260 @@
local M = {}
local utils = require('fff.utils')
local canonicalize_fff_path = utils.canonicalize_fff_path
-- State structure definitions
M.state = {
-- UI state
active = false,
layout = nil,
input_win = nil,
input_buf = nil,
list_win = nil,
list_buf = nil,
file_info_win = nil,
file_info_buf = nil,
preview_win = nil,
preview_buf = nil,
preview_visible = false,
-- Data state
items = {},
filtered_items = {},
cursor = 1,
top = 1,
query = '',
line_to_item = {},
item_to_lines = {},
last_render_ctx = nil,
location = nil,
-- Cursor index to restore after the next search completes (set on resume).
-- Lets the re-search run for fresh results while keeping the saved position.
pending_restore_cursor = nil,
-- History cycling state
history_offset = nil,
next_search_force_combo_boost = false,
-- Combo state
combo_visible = true,
combo_initial_cursor = nil,
-- History cycling state (tracked alongside combo state)
updating_from_history = false,
-- Pagination state
pagination = {
page_index = 0,
page_size = 20,
total_matched = 0,
prefetch_margin = 5,
grep_file_offsets = {},
grep_next_file_offset = 0,
},
-- Configuration and mode
config = nil,
renderer = nil,
mode = nil,
grep_mode = 'plain',
grep_config = nil,
grep_regex_fallback_error = nil,
-- Selection state
selected_files = {},
selected_file_order = {},
selected_items = {},
-- Cross-mode suggestion state
suggestion_items = nil,
suggestion_source = nil,
-- Preview state
last_preview_file = nil,
last_preview_location = nil,
preview_timer = nil,
preview_debounce_ms = 10,
-- Misc
ns_id = nil,
last_status_info = nil,
restore_paste = false,
current_file_cache = nil,
}
-- Helper function to generate grep item keys
local function grep_item_key(item)
return string.format('%s:%d:%d', item.relative_path, item.line_number or 1, item.col or 0)
end
-- Reset history-related state
function M.reset_history_state()
M.state.history_offset = nil
M.state.next_search_force_combo_boost = false
M.state.combo_visible = true
M.state.combo_initial_cursor = nil
M.state.updating_from_history = false
end
-- Complete state reset (called on close)
function M.reset_state()
M.state.active = false
M.state.layout = nil
M.state.input_win = nil
M.state.input_buf = nil
M.state.list_win = nil
M.state.list_buf = nil
M.state.file_info_win = nil
M.state.file_info_buf = nil
M.state.preview_win = nil
M.state.preview_buf = nil
M.state.preview_visible = false
M.state.items = {}
M.state.filtered_items = {}
M.state.cursor = 1
M.state.top = 1
M.state.query = ''
M.state.line_to_item = {}
M.state.item_to_lines = {}
M.state.last_render_ctx = nil
M.state.location = nil
M.state.pending_restore_cursor = nil
M.reset_history_state()
M.state.pagination = {
page_index = 0,
page_size = 20,
total_matched = 0,
prefetch_margin = 5,
grep_file_offsets = {},
grep_next_file_offset = 0,
}
M.state.config = nil
M.state.renderer = nil
M.state.mode = nil
M.state.grep_mode = 'plain'
M.state.grep_config = nil
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
M.state.last_preview_file = nil
M.state.last_preview_location = nil
M.state.preview_timer = nil
M.state.current_file_cache = nil
M.state.ns_id = nil
M.state.last_status_info = nil
M.state.restore_paste = false
M.state.combo_visible = true
M.state.combo_initial_cursor = nil
M.state.updating_from_history = false
end
-- Clear all selections
function M.clear_selections()
M.state.selected_files = {}
M.state.selected_file_order = {}
M.state.selected_items = {}
end
-- Get selected items for quickfix list
function M.get_selected_items()
local selected = {}
if M.state.mode == 'grep' then
-- Grep mode: return selected items
for _, item in pairs(M.state.selected_items) do
table.insert(selected, item)
end
else
-- Normal file mode: return selected files
for relative_path, _ in pairs(M.state.selected_files) do
local abs_path = canonicalize_fff_path(relative_path)
if abs_path then table.insert(selected, { relative_path = relative_path }) end
end
end
return selected
end
-- Toggle item selection (called from keymaps via M.toggle_select)
function M.toggle_selection()
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
local item = items[M.state.cursor]
if not item or not item.relative_path then return end
local was_selected
if M.state.mode == 'grep' then
-- Per-occurrence selection for grep mode
local key = grep_item_key(item)
was_selected = M.state.selected_items[key] ~= nil
if was_selected then
M.state.selected_items[key] = nil
else
M.state.selected_items[key] = item
end
else
-- Per-file selection for normal file mode
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
+236
View File
@@ -0,0 +1,236 @@
local M = {}
local preview = require('fff.file_picker.preview')
local utils = require('fff.utils')
local picker_ui_state = require('fff.picker_ui.picker_ui_state')
local canonicalize_fff_path = utils.canonicalize_fff_path
-- Parent module reference (set by picker_ui.lua during initialization).
---@type table
local P = nil
function M.init(parent_module) P = parent_module end
local S = picker_ui_state.state
function M.close_preview_timer(timer)
timer = timer or S.preview_timer
if not timer then return end
if S.preview_timer == timer then S.preview_timer = nil end
if timer:is_closing() then return end
timer:stop()
timer:close()
end
function M.update_preview_debounced()
M.close_preview_timer()
local timer = vim.uv.new_timer()
S.preview_timer = timer
timer:start(
S.preview_debounce_ms,
0,
vim.schedule_wrap(function()
local is_current = S.preview_timer == timer
M.close_preview_timer(timer)
if is_current and P.state.active then M.update_preview() end
end)
)
end
--- Smart preview update for cursor movement.
function M.update_preview_smart()
if not S.preview_visible then return end
if not P.state.active then return end
local items = S.filtered_items
if #items == 0 or S.cursor > #items then
M.update_preview()
return
end
local item = items[S.cursor]
if not item then
M.update_preview()
return
end
if S.last_preview_file == item.relative_path then
M.update_preview()
return
end
M.update_preview_debounced()
end
function M.update_preview_title(item, location)
if not S.preview_win or not vim.api.nvim_win_is_valid(S.preview_win) then return end
local relative_path = item.relative_path
local max_title_width = vim.api.nvim_win_get_width(S.preview_win)
local suffix = ''
local is_grep_item = S.mode == 'grep' or S.suggestion_source == 'grep'
if is_grep_item and location and location.line then suffix = ':' .. tostring(location.line) end
local display_path = relative_path .. suffix
local title
if #display_path + 2 <= max_title_width then
title = string.format(' %s ', display_path)
else
local available_chars = max_title_width - 2
local filename = vim.fn.fnamemodify(relative_path, ':t') .. suffix
if available_chars <= 3 then
title = filename
else
if #filename + 5 <= available_chars then
local normalized_path = vim.fs.normalize(relative_path)
local path_parts = vim.split(normalized_path, '[/\\]', { plain = false })
local segments = {}
for _, part in ipairs(path_parts) do
if part ~= '' then table.insert(segments, part) end
end
segments[#segments] = vim.fn.fnamemodify(relative_path, ':t') .. suffix
local segments_to_show = { segments[#segments] }
local current_length = #segments_to_show[1] + 4
for i = #segments - 1, 1, -1 do
local segment = segments[i]
local new_length = current_length + #segment + 1
if new_length <= available_chars then
table.insert(segments_to_show, 1, segment)
current_length = new_length
else
break
end
end
if #segments_to_show == #segments then
title = string.format(' %s ', table.concat(segments_to_show, '/'))
else
title = string.format(' ../%s ', table.concat(segments_to_show, '/'))
end
else
local truncated = filename:sub(1, available_chars - 3) .. '...'
title = string.format(' %s ', truncated)
end
end
end
vim.api.nvim_win_set_config(S.preview_win, {
title = title,
title_pos = 'left',
})
end
function M.update_preview()
if not S.preview_visible then return end
if not P.state.active then return end
local items = S.filtered_items
if #items == 0 or S.cursor > #items then
M.clear_preview()
S.last_preview_file = nil
S.last_preview_location = nil
return
end
local item = items[S.cursor]
if not item then
M.clear_preview()
S.last_preview_file = nil
S.last_preview_location = nil
return
end
local effective_location = S.location
if not effective_location and S.query and S.query ~= '' then
local line_str = S.query:match(':(%d+)$')
if line_str then
local line_num = tonumber(line_str)
if line_num and line_num > 0 then
local l, c = S.query:match(':(%d+):(%d+)$')
if l then
effective_location = { line = tonumber(l), col = tonumber(c) }
else
effective_location = { line = line_num }
end
end
end
end
local is_grep_item = S.mode == 'grep' or S.suggestion_source == 'grep'
if is_grep_item and item.line_number and item.line_number > 0 then
effective_location = { line = item.line_number }
if item.col and item.col > 0 then effective_location.col = item.col + 1 end
effective_location.grep_query = S.query
if S.grep_mode == 'fuzzy' and item.match_ranges then effective_location.fuzzy_match_ranges = item.match_ranges end
end
local location_changed = not vim.deep_equal(S.last_preview_location, effective_location)
if S.last_preview_file == item.relative_path and not location_changed then return end
if S.last_preview_file == item.relative_path and location_changed then
S.last_preview_location = effective_location and vim.deepcopy(effective_location) or nil
preview.state.location = effective_location
if is_grep_item and effective_location and effective_location.line then
M.update_preview_title(item, effective_location)
end
if S.preview_buf and vim.api.nvim_buf_is_valid(S.preview_buf) then
preview.apply_location_highlighting(S.preview_buf)
end
return
end
preview.clear()
S.last_preview_file = item.relative_path
S.last_preview_location = effective_location and vim.deepcopy(effective_location) or nil
M.update_preview_title(item, effective_location)
if S.file_info_buf then
preview.update_file_info_buffer(item, S.file_info_buf, S.cursor, S.preview_win)
if S.file_info_win and vim.api.nvim_win_is_valid(S.file_info_win) then
local rel = item.relative_path or item.path or ''
pcall(vim.api.nvim_win_set_config, S.file_info_win, { title = ' ' .. rel .. ' ', title_pos = 'left' })
end
end
preview.set_preview_window(S.preview_win)
preview.preview(canonicalize_fff_path(item.relative_path), S.preview_buf, effective_location, item.is_binary)
end
function M.clear_preview()
if not P.state.active then return end
if not S.preview_visible then return end
vim.api.nvim_win_set_config(S.preview_win, {
title = ' Preview ',
title_pos = 'left',
})
if S.file_info_buf then
vim.api.nvim_set_option_value('modifiable', true, { buf = S.file_info_buf })
vim.api.nvim_buf_set_lines(S.file_info_buf, 0, -1, false, {})
vim.api.nvim_set_option_value('modifiable', false, { buf = S.file_info_buf })
pcall(vim.api.nvim_buf_clear_namespace, S.file_info_buf, preview.file_info_ns, 0, -1)
end
vim.api.nvim_set_option_value('modifiable', true, { buf = S.preview_buf })
vim.api.nvim_buf_set_lines(S.preview_buf, 0, -1, false, { 'No preview available' })
vim.api.nvim_set_option_value('modifiable', false, { buf = S.preview_buf })
end
return M
+324
View File
@@ -0,0 +1,324 @@
local M = {}
local rust = require('fff.rust')
local list_separator = require('fff.list_separator')
local scrollbar = require('fff.scrollbar')
local list_renderer = require('fff.picker_ui.list_renderer')
local combo_renderer = require('fff.picker_ui.combo_renderer')
local file_picker = require('fff.file_picker')
local layout = require('fff.layout')
local picker_ui_state = require('fff.picker_ui.picker_ui_state')
-- Parent module reference (set by picker_ui.lua during initialization).
-- Allows renderer functions to call back into the main picker module.
---@type table
local P = nil
function M.init(parent_module) P = parent_module end
-- Convenience alias
local S = picker_ui_state.state
local function get_prompt_position() return layout.resolve_prompt_position(S.config) end
local function shrink_path(path, max_width)
local config = S.config or {}
local strategy = config.layout and config.layout.path_shorten_strategy or 'middle_number'
return rust.shorten_path(path, max_width, strategy)
end
local function format_file_display(item, max_width)
local filename = item.name
if type(filename) ~= 'string' then filename = filename and tostring(filename) or '' end
local dir_path = item.directory or ''
if type(dir_path) ~= 'string' then dir_path = dir_path and tostring(dir_path) or '' end
if dir_path == '' and item.relative_path then
local parent_dir = vim.fn.fnamemodify(item.relative_path, ':h')
if parent_dir ~= '.' and parent_dir ~= '' then dir_path = parent_dir end
end
local filename_width = vim.fn.strdisplaywidth(filename)
local base_width = filename_width + 1
local path_max_width = math.max(max_width - base_width, 0)
if dir_path == '' or path_max_width == 0 then return filename, '' end
local display_path = shrink_path(dir_path, path_max_width)
return filename, display_path
end
--- Adjust scroll for bottom prompt to eliminate gaps.
function M.scroll_to_bottom()
if not S.list_win or not vim.api.nvim_win_is_valid(S.list_win) then return end
local win_height = vim.api.nvim_win_get_height(S.list_win)
local buf_lines = vim.api.nvim_buf_line_count(S.list_buf)
vim.api.nvim_win_call(S.list_win, function()
local view = vim.fn.winsaveview()
local bottom_topline = math.max(1, buf_lines - win_height + 1)
local cursor_line = vim.api.nvim_win_get_cursor(S.list_win)[1]
if cursor_line >= bottom_topline then
view.topline = bottom_topline
elseif cursor_line < view.topline then
view.topline = math.max(1, cursor_line - 1)
elseif cursor_line >= view.topline + win_height then
view.topline = bottom_topline
end
vim.fn.winrestview(view)
end)
end
--- Render the grep empty state.
local function render_grep_empty_state(ctx)
list_separator.hide()
local config = ctx.config
local win_width = ctx.win_width
local win_height = ctx.win_height
local prompt_position = ctx.prompt_position
local content = {}
local hl_cmds = {}
table.insert(content, '')
table.insert(content, ' Start typing to search file contents...')
table.insert(content, '')
table.insert(content, ' Tips:')
table.insert(content, ' "pattern *.rs" search only in Rust files')
table.insert(content, ' "pattern /src/" limit search to src/ directory')
table.insert(content, ' "!test pattern" exclude test files')
table.insert(content, '')
if prompt_position == 'bottom' then
local empty_needed = math.max(0, win_height - #content)
for _ = 1, empty_needed do
table.insert(content, 1, string.rep(' ', win_width + 5))
for _, h in ipairs(hl_cmds) do
h.row = h.row + 1
end
end
end
vim.api.nvim_set_option_value('modifiable', true, { buf = S.list_buf })
vim.api.nvim_buf_set_lines(S.list_buf, 0, -1, false, content)
vim.api.nvim_set_option_value('modifiable', false, { buf = S.list_buf })
vim.api.nvim_buf_clear_namespace(S.list_buf, S.ns_id, 0, -1)
if prompt_position == 'bottom' then M.scroll_to_bottom() end
for _, h in ipairs(hl_cmds) do
pcall(
vim.api.nvim_buf_set_extmark,
S.list_buf,
S.ns_id,
h.row,
h.col_start,
{ end_col = h.col_end, hl_group = h.hl }
)
end
for i = 0, #content - 1 do
local line = content[i + 1]
if
line and (line:match('^%s+Start typing') or line:match('^%s+Tips') or line:match('^%s+"') or line:match('^%s+!'))
then
pcall(
vim.api.nvim_buf_set_extmark,
S.list_buf,
S.ns_id,
i,
0,
{ end_row = i + 1, end_col = 0, hl_group = 'Comment' }
)
end
if line and (line:match('^%s+[╭╰│]') or line:match('[╮╯│]%s*$')) then
pcall(
vim.api.nvim_buf_set_extmark,
S.list_buf,
S.ns_id,
i,
0,
{ end_row = i + 1, end_col = 0, hl_group = config.hl.border or 'FloatBorder' }
)
end
end
end
--- Build rendering context with all necessary data.
local function build_render_context()
local config = S.config or {}
local items = S.filtered_items
local win_height = vim.api.nvim_win_get_height(S.list_win)
local win_width = vim.api.nvim_win_get_width(S.list_win)
local prompt_position = get_prompt_position()
local win_info = vim.fn.getwininfo(S.list_win)[1]
local text_offset = win_info and win_info.textoff or 2
local text_width = win_width - text_offset
local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100
local separator = nil
local combo_info = nil
if S.mode ~= 'grep' and not S.suggestion_source then
combo_info = combo_renderer.detect(
items,
file_picker,
combo_boost_score_multiplier,
S.next_search_force_combo_boost or config.history.min_combo_count == 0
)
end
S.next_search_force_combo_boost = false
if combo_info and S.combo_visible then
separator = {
idx = combo_info.idx,
text = combo_info.text,
text_hl = config.hl.combo_header,
border_hl = config.hl.border,
}
S.combo_initial_cursor = combo_info.idx
end
local display_start = 1
local display_end = #items
if separator and (display_end - display_start + 1) >= win_height then
if separator.idx == display_start then
display_end = display_end - 1
else
display_start = display_start + 1
end
end
local iter_start, iter_end, iter_step
if prompt_position == 'bottom' then
iter_start, iter_end, iter_step = display_end, display_start, -1
else
iter_start, iter_end, iter_step = display_start, display_end, 1
end
return {
config = config,
items = items,
cursor = S.cursor,
win_height = win_height,
win_width = win_width,
max_path_width = text_width,
debug_enabled = config and config.debug and config.debug.show_scores,
prompt_position = prompt_position,
separator = separator,
combo_info = combo_info,
display_start = display_start,
display_end = display_end,
iter_start = iter_start,
iter_end = iter_end,
iter_step = iter_step,
renderer = S.suggestion_source and P.get_suggestion_renderer() or S.renderer,
query = S.query,
selected_files = S.selected_files,
selected_items = S.selected_items,
mode = S.mode,
format_file_display = format_file_display,
suggestion_source = S.suggestion_source,
}
end
local function finalize_render(separator_line, ctx)
if ctx.separator and separator_line then
local arrow = ctx.prompt_position == 'bottom' and '' or ''
local list_cfg = vim.api.nvim_win_get_config(S.list_win)
local screen_row = list_cfg.row + separator_line
list_separator.update({
list_win = S.list_win,
row = screen_row,
text = arrow .. ' ' .. ctx.separator.text,
text_hl = ctx.separator.text_hl,
border_hl = ctx.separator.border_hl,
})
else
list_separator.hide()
end
if ctx.mode ~= 'grep' and not ctx.suggestion_source then
scrollbar.render(S.layout, ctx.config, S.list_win, S.pagination, ctx.prompt_position)
end
end
function M.render_list()
if not P.state.active then return end
local ctx = build_render_context()
if S.mode == 'grep' and #ctx.items == 0 then
S.line_to_item = {}
S.item_to_lines = {}
S.last_render_ctx = nil
render_grep_empty_state(ctx)
return
end
local item_to_lines, separator_line = list_renderer.render(ctx, S.list_buf, S.list_win, S.ns_id)
S.item_to_lines = item_to_lines
S.last_render_ctx = ctx
local line_to_item = {}
for item_idx, mapping in pairs(item_to_lines) do
for line = mapping.first, mapping.last do
line_to_item[line] = item_idx
end
end
S.line_to_item = line_to_item
if ctx.prompt_position == 'bottom' then M.scroll_to_bottom() end
finalize_render(separator_line, ctx)
end
local function rerender_cursor_rows(old_cursor, new_cursor)
if S.suggestion_source then return false end
local ctx = S.last_render_ctx
if not ctx or ctx.items ~= S.filtered_items then return false end
if ctx.renderer and not ctx.renderer.supports_cursor_rerender then return false end
local old_lines = S.item_to_lines[old_cursor]
local new_lines = S.item_to_lines[new_cursor]
if not old_lines or not new_lines then return false end
local renderer = ctx.renderer or require('fff.picker_ui.file_renderer')
local entries = {
{ item_idx = old_cursor, lines = old_lines },
{ item_idx = new_cursor, lines = new_lines },
}
for _, entry in ipairs(entries) do
entry.item = ctx.items[entry.item_idx]
if not entry.item then return false end
local line_idx = entry.lines.last
entry.line = vim.api.nvim_buf_get_lines(S.list_buf, line_idx - 1, line_idx, false)[1]
if not entry.line then return false end
end
ctx.cursor = new_cursor
for _, entry in ipairs(entries) do
vim.api.nvim_buf_clear_namespace(S.list_buf, S.ns_id, entry.lines.first - 1, entry.lines.last)
renderer.apply_highlights(entry.item, ctx, entry.item_idx, S.list_buf, S.ns_id, entry.lines.last, entry.line)
end
vim.api.nvim_win_set_cursor(S.list_win, { new_lines.last, 0 })
return true
end
function M.render_after_cursor_move(old_cursor)
if old_cursor == S.cursor then return false end
if old_cursor and rerender_cursor_rows(old_cursor, S.cursor) then return true end
M.render_list()
return true
end
return M
+367
View File
@@ -0,0 +1,367 @@
local M = {}
local file_picker = require('fff.file_picker')
local grep = require('fff.picker_ui.grep_renderer')
local picker_ui_state = require('fff.picker_ui.picker_ui_state')
-- Parent module reference (set by picker_ui.lua during initialization).
-- Allows search_manager functions to call back into the main picker module.
---@type table
local P = nil
function M.init(parent_module) P = parent_module end
-- Convenience alias
local S = picker_ui_state.state
function M.update_results() M.update_results_sync() end
function M.update_results_sync()
if not P.state.active then return end
if not S.current_file_cache then
local current_buf = vim.api.nvim_get_current_buf()
if current_buf and vim.api.nvim_buf_is_valid(current_buf) then
local current_file = vim.api.nvim_buf_get_name(current_buf)
S.current_file_cache = (current_file ~= '' and vim.fn.filereadable(current_file) == 1) and current_file or nil
end
end
local page_size
if S.list_win and vim.api.nvim_win_is_valid(S.list_win) then
page_size = vim.api.nvim_win_get_height(S.list_win)
else
page_size = S.config.max_results or 100
end
S.pagination.page_size = page_size
S.pagination.page_index = 0
S.combo_visible = true
S.combo_initial_cursor = 1
local min_combo_override = nil
if S.next_search_force_combo_boost then min_combo_override = 0 end
local results
if S.mode == 'grep' then
S.grep_regex_fallback_error = nil
if S.query == '' then
results = {}
S.pagination.total_matched = 0
S.pagination.grep_file_offsets = {}
S.pagination.grep_next_file_offset = 0
else
local grep_result = grep.search(S.query, 0, page_size, S.grep_config, S.grep_mode)
results = grep_result.items or {}
S.pagination.total_matched = grep_result.total_matched or 0
S.pagination.grep_file_offsets = { 0 }
S.pagination.grep_next_file_offset = grep_result.next_file_offset or 0
S.grep_regex_fallback_error = grep_result.regex_fallback_error or nil
if grep_result.next_file_offset and grep_result.next_file_offset > 0 then
S.pagination.grep_file_offsets[2] = grep_result.next_file_offset
end
end
S.location = nil
else
results = file_picker.search_files_paginated(
S.query,
S.current_file_cache,
S.config.max_threads,
min_combo_override,
0,
page_size
)
S.location = file_picker.get_search_location()
local metadata = file_picker.get_search_metadata()
S.pagination.total_matched = metadata.total_matched
end
S.items = results
S.filtered_items = results
S.suggestion_items = nil
S.suggestion_source = nil
if #results == 0 and S.query ~= '' then
if S.mode == 'grep' then
local suggestion_results =
file_picker.search_files_paginated(S.query, S.current_file_cache, S.config.max_threads, nil, 0, page_size)
if suggestion_results and #suggestion_results > 0 then
S.suggestion_items = suggestion_results
S.suggestion_source = 'files'
end
else
local grep_result = grep.search(S.query, 0, page_size, S.grep_config, 'plain')
local grep_items = grep_result and grep_result.items or {}
if #grep_items > 0 then
S.suggestion_items = grep_items
S.suggestion_source = 'grep'
end
end
end
if S.suggestion_items and #S.suggestion_items > 0 then S.filtered_items = S.suggestion_items end
-- On resume, restore the saved cursor onto the fresh results (clamped, since
-- the result set may have changed since close). Otherwise reset to the top.
if S.pending_restore_cursor then
S.cursor = math.max(1, math.min(S.pending_restore_cursor, #S.filtered_items))
S.pending_restore_cursor = nil
else
S.cursor = 1
end
P.render_debounced()
end
function M.load_page_at_index(new_page_index, adjust_cursor_fn)
local ok, err, results
local page_size = S.pagination.page_size
if page_size == 0 then return false end
if S.mode ~= 'grep' then
local total = S.pagination.total_matched
if total == 0 then return false end
local max_page_index = math.max(0, math.ceil(total / page_size) - 1)
new_page_index = math.max(0, math.min(new_page_index, max_page_index))
end
if S.mode == 'grep' then
local file_offset = S.pagination.grep_file_offsets[new_page_index + 1]
if file_offset == nil then return false end
ok, results = pcall(grep.search, S.query, file_offset, page_size, S.grep_config, S.grep_mode)
if ok and results then
local grep_result = results
results = grep_result.items or {}
S.pagination.total_matched = grep_result.total_matched or 0
S.pagination.grep_next_file_offset = grep_result.next_file_offset or 0
S.grep_regex_fallback_error = grep_result.regex_fallback_error or nil
if grep_result.next_file_offset and grep_result.next_file_offset > 0 then
S.pagination.grep_file_offsets[new_page_index + 2] = grep_result.next_file_offset
end
end
else
ok, results = pcall(
file_picker.search_files_paginated,
S.query,
S.current_file_cache,
S.config.max_threads,
nil,
new_page_index,
page_size
)
end
if not ok then
vim.notify('Error in paginated search: ' .. tostring(results), vim.log.levels.ERROR)
return false
end
if #results == 0 then return false end
if S.mode ~= 'grep' then
local metadata = file_picker.get_search_metadata()
S.pagination.total_matched = metadata.total_matched
end
S.items = results
S.filtered_items = results
S.pagination.page_index = new_page_index
if adjust_cursor_fn then
local cursor_ok, cursor_err = pcall(adjust_cursor_fn, #results)
if not cursor_ok then
vim.notify('Error in cursor adjustment: ' .. tostring(cursor_err), vim.log.levels.ERROR)
return false
end
end
ok, err = pcall(P.render_list)
if not ok then
vim.notify('Error in render_list: ' .. tostring(err), vim.log.levels.ERROR)
return false
end
ok, err = pcall(P.update_preview)
if not ok then
vim.notify('Error in update_preview: ' .. tostring(err), vim.log.levels.ERROR)
return false
end
ok, err = pcall(P.update_status)
if not ok then
vim.notify('Error in update_status: ' .. tostring(err), vim.log.levels.ERROR)
return false
end
return true
end
function M.load_next_page()
local page_size = S.pagination.page_size
local current_page = S.pagination.page_index
if page_size == 0 then return false end
if S.mode == 'grep' then
if S.pagination.grep_next_file_offset == 0 then return false end
return M.load_page_at_index(current_page + 1, function() S.cursor = 1 end)
end
local total = S.pagination.total_matched
if total == 0 then return false end
local max_page_index = math.max(0, math.ceil(total / page_size) - 1)
if current_page >= max_page_index then return false end
return M.load_page_at_index(current_page + 1, function() S.cursor = 1 end)
end
function M.load_previous_page()
if S.pagination.page_index == 0 then return false end
return M.load_page_at_index(S.pagination.page_index - 1, function(result_count) S.cursor = result_count end)
end
function M.on_input_change()
if not P.state.active then return end
local lines = vim.api.nvim_buf_get_lines(S.input_buf, 0, -1, false)
local prompt_len = #S.config.prompt
local query = ''
if #lines > 1 then
local all_text = table.concat(lines, '')
if all_text:sub(1, prompt_len) == S.config.prompt then
query = all_text:sub(prompt_len + 1)
else
query = all_text
end
query = query:gsub('\r', ''):match('^%s*(.-)%s*$') or ''
vim.api.nvim_set_option_value('modifiable', true, { buf = S.input_buf })
vim.api.nvim_buf_set_lines(S.input_buf, 0, -1, false, { S.config.prompt .. query })
vim.schedule(function()
if P.state.active and S.input_win and vim.api.nvim_win_is_valid(S.input_win) then
vim.api.nvim_win_set_cursor(S.input_win, { 1, prompt_len + #query })
end
end)
else
local full_line = lines[1] or ''
if full_line:sub(1, prompt_len) == S.config.prompt then query = full_line:sub(prompt_len + 1) end
end
S.query = query
M.update_results_sync()
end
function M.cycle_grep_modes()
if not P.state.active or S.mode ~= 'grep' then return end
---@diagnostic disable-next-line: undefined-field
local modes = (S.grep_config and S.grep_config.modes) or S.config.grep.modes or { 'plain', 'regex', 'fuzzy' }
if #modes <= 1 then return end
local current_idx = 1
for i, m in ipairs(modes) do
if m == S.grep_mode then
current_idx = i
break
end
end
S.grep_mode = modes[(current_idx % #modes) + 1]
if S.grep_mode ~= 'regex' then S.grep_regex_fallback_error = nil end
S.last_status_info = nil
P.update_status()
if S.query ~= '' then M.update_results_sync() end
end
function M.recall_query_from_history()
if not P.state.active then return end
if S.history_offset == nil then
S.history_offset = 0
else
S.history_offset = S.history_offset + 1
end
local fuzzy = require('fff.core').ensure_initialized()
local history_fn = S.mode == 'grep' and fuzzy.get_historical_grep_query or fuzzy.get_historical_query
local ok, query = pcall(history_fn, S.history_offset)
if not ok or not query then
S.history_offset = 0
ok, query = pcall(history_fn, 0)
if not ok or not query then
vim.notify('No query history available', vim.log.levels.INFO)
S.history_offset = nil
return
end
end
if S.mode ~= 'grep' then S.next_search_force_combo_boost = true end
vim.api.nvim_buf_set_lines(S.input_buf, 0, -1, false, { S.config.prompt .. query })
vim.schedule(function()
if P.state.active and S.input_win and vim.api.nvim_win_is_valid(S.input_win) then
vim.api.nvim_win_set_cursor(S.input_win, { 1, #S.config.prompt + #query })
end
end)
end
function M.cycle_forward_query()
if not P.state.active then return end
if S.history_offset == nil then
-- At top of stack (fresh open or resume with pre-filled input).
-- Clear input to return to a clean slate.
S.history_offset = nil
vim.api.nvim_buf_set_lines(S.input_buf, 0, -1, false, { S.config.prompt })
return
elseif S.history_offset == 0 then
-- At the most recent history entry, go back to present
S.history_offset = nil
vim.api.nvim_buf_set_lines(S.input_buf, 0, -1, false, { S.config.prompt })
return
else
S.history_offset = S.history_offset - 1
end
local fuzzy = require('fff.core').ensure_initialized()
local history_fn = S.mode == 'grep' and fuzzy.get_historical_grep_query or fuzzy.get_historical_query
local ok, query = pcall(history_fn, S.history_offset)
if not ok or not query then
S.history_offset = nil
return
end
if S.mode ~= 'grep' then S.next_search_force_combo_boost = true end
vim.api.nvim_buf_set_lines(S.input_buf, 0, -1, false, { S.config.prompt .. query })
vim.schedule(function()
if P.state.active and S.input_win and vim.api.nvim_win_is_valid(S.input_win) then
vim.api.nvim_win_set_cursor(S.input_win, { 1, #S.config.prompt + #query })
end
end)
end
function M.get_suggestion_renderer()
if S.suggestion_source == 'grep' then
return require('fff.picker_ui.grep_renderer')
else
return require('fff.picker_ui.file_renderer')
end
end
return M
+500
View File
@@ -0,0 +1,500 @@
local M = {}
local conf = require('fff.conf')
local layout = require('fff.layout')
local preview = require('fff.file_picker.preview')
local list_separator = require('fff.list_separator')
local picker_ui_state = require('fff.picker_ui.picker_ui_state')
-- Parent module reference (set by picker_ui.lua during initialization).
-- Allows ui_creator functions to call back into the main picker module.
---@type table
local P = nil
function M.init(parent_module) P = parent_module end
-- Convenience alias
local S = picker_ui_state.state
local function resolve_winhl(kind)
local hl = S.config.hl
local winhl = hl.winhl
local default_winhl = string.format('Normal:%s,FloatBorder:%s,FloatTitle:%s', hl.normal, hl.border, hl.title)
if winhl == nil then return default_winhl end
if type(winhl) == 'string' then return winhl end
if type(winhl) == 'table' then return winhl[kind] or default_winhl end
return default_winhl
end
local function open_preview(win_cfg)
if not win_cfg then return end
if S.preview_win and vim.api.nvim_win_is_valid(S.preview_win) then return end
if not (S.preview_buf and vim.api.nvim_buf_is_valid(S.preview_buf)) then
S.preview_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = S.preview_buf })
vim.api.nvim_buf_set_name(S.preview_buf, 'fffile preview')
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = S.preview_buf })
vim.api.nvim_set_option_value('filetype', 'fff_preview', { buf = S.preview_buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = S.preview_buf })
end
S.preview_win = vim.api.nvim_open_win(S.preview_buf, false, win_cfg)
local win_hl = resolve_winhl('preview')
vim.api.nvim_set_option_value('wrap', false, { win = S.preview_win })
vim.api.nvim_set_option_value('cursorline', false, { win = S.preview_win })
vim.api.nvim_set_option_value('cursorlineopt', vim.o.cursorlineopt, { win = S.preview_win })
vim.api.nvim_set_option_value(
'number',
S.mode == 'grep' or (conf.get().preview and conf.get().preview.line_numbers or false),
{ win = S.preview_win }
)
vim.api.nvim_set_option_value('relativenumber', false, { win = S.preview_win })
vim.api.nvim_set_option_value('signcolumn', 'no', { win = S.preview_win })
vim.api.nvim_set_option_value('foldcolumn', '0', { win = S.preview_win })
vim.api.nvim_set_option_value('winhighlight', win_hl, { win = S.preview_win })
preview.set_preview_window(S.preview_win)
end
local function close_preview()
if S.preview_win and vim.api.nvim_win_is_valid(S.preview_win) then vim.api.nvim_win_close(S.preview_win, true) end
S.preview_win = nil
if S.preview_buf and vim.api.nvim_buf_is_valid(S.preview_buf) then
preview.clear_buffer(S.preview_buf)
vim.api.nvim_buf_delete(S.preview_buf, { force = true })
end
S.preview_buf = nil
S.last_preview_file = nil
S.last_preview_location = nil
end
local function set_keymap(mode, keys, handler, opts)
local normalized_keys
if type(keys) == 'string' then
normalized_keys = { keys }
elseif type(keys) == 'table' then
normalized_keys = keys
else
normalized_keys = {}
end
for _, key in ipairs(normalized_keys) do
vim.keymap.set(mode, key, handler, opts)
end
end
local function handle_mouse_click_or_fallback(action, fallback)
local pos = vim.fn.getmousepos()
if P.state.active and pos.winid == S.list_win then
local item_idx = S.line_to_item[pos.line]
if not item_idx then return '' end
vim.schedule(function()
if not P.state.active then return end
if not S.filtered_items[item_idx] then return end
if S.cursor ~= item_idx then
S.cursor = item_idx
P.render_list()
if S.mode == 'grep' or S.suggestion_source == 'grep' then
P.update_preview_smart()
else
P.update_preview()
end
P.update_status()
end
if action then P.select(action) end
end)
return ''
end
return fallback
end
local function move_list_cursor(direction)
if not P.state.active then return end
local items = S.filtered_items
if #items == 0 then return end
local wrap_around = S.config and S.config.wrap_around or false
local new_cursor = S.cursor + direction
if wrap_around then
if new_cursor < 1 then
new_cursor = #items
elseif new_cursor > #items then
new_cursor = 1
end
else
new_cursor = math.max(1, math.min(new_cursor, #items))
end
if new_cursor ~= S.cursor then
S.cursor = new_cursor
P.render_list()
if S.mode == 'grep' or S.suggestion_source == 'grep' then
P.update_preview_smart()
else
P.update_preview()
end
P.update_status()
end
end
function M.create_ui()
local config = S.config
if not config then return false end
-- Prompt editing should behave consistently even if the user has :set paste.
S.restore_paste = (function()
if not vim.o.paste then return false end
vim.o.paste = false
return true
end)()
if not S.ns_id then
S.ns_id = vim.api.nvim_create_namespace('fff_picker_status')
list_separator.init(S.ns_id)
end
local computed_layout = layout.compute(config, conf.preview_enabled(config))
local win_configs = computed_layout.win_configs
local debug_enabled_in_preview = computed_layout.debug_enabled
S.layout = computed_layout.layout
S.preview_visible = computed_layout.preview_visible
S.input_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = S.input_buf })
S.list_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = S.list_buf })
if debug_enabled_in_preview then
S.file_info_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = S.file_info_buf })
else
S.file_info_buf = nil
end
S.list_win = vim.api.nvim_open_win(S.list_buf, false, win_configs.list)
if debug_enabled_in_preview and win_configs.file_info then
S.file_info_win = vim.api.nvim_open_win(S.file_info_buf, false, win_configs.file_info)
else
S.file_info_win = nil
end
if S.preview_visible then open_preview(win_configs.preview) end
S.input_win = vim.api.nvim_open_win(S.input_buf, false, win_configs.input)
M.setup_buffers()
M.setup_windows()
M.setup_keymaps()
vim.api.nvim_set_current_win(S.input_win)
P.update_results_sync()
P.clear_preview()
P.update_status()
return true
end
function M.setup_buffers()
vim.api.nvim_buf_set_name(S.input_buf, 'fffile search')
vim.api.nvim_buf_set_name(S.list_buf, 'fffiles list')
vim.api.nvim_set_option_value('buftype', 'prompt', { buf = S.input_buf })
vim.api.nvim_set_option_value('filetype', 'fff_input', { buf = S.input_buf })
vim.fn.prompt_setprompt(S.input_buf, S.config.prompt)
-- Changing the contents of the input buffer will trigger Neovim to guess the language
-- in order to provide syntax highlighting. This makes sure that it's always off.
vim.api.nvim_create_autocmd('Syntax', {
buffer = S.input_buf,
callback = function() vim.api.nvim_set_option_value('syntax', '', { buf = S.input_buf }) end,
})
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = S.list_buf })
vim.api.nvim_set_option_value('filetype', 'fff_list', { buf = S.list_buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = S.list_buf })
if S.file_info_buf then
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = S.file_info_buf })
vim.api.nvim_set_option_value('filetype', 'fff_file_info', { buf = S.file_info_buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = S.file_info_buf })
end
end
function M.setup_windows()
local prompt_win_hl = resolve_winhl('prompt')
local list_win_hl = resolve_winhl('list')
local file_info_win_hl = resolve_winhl('file_info')
vim.api.nvim_set_option_value('wrap', false, { win = S.input_win })
vim.api.nvim_set_option_value('cursorline', false, { win = S.input_win })
vim.api.nvim_set_option_value('number', false, { win = S.input_win })
vim.api.nvim_set_option_value('relativenumber', false, { win = S.input_win })
vim.api.nvim_set_option_value('signcolumn', 'no', { win = S.input_win })
vim.api.nvim_set_option_value('foldcolumn', '0', { win = S.input_win })
vim.api.nvim_set_option_value('winhighlight', prompt_win_hl, { win = S.input_win })
vim.api.nvim_set_option_value('wrap', false, { win = S.list_win })
vim.api.nvim_set_option_value('cursorline', false, { win = S.list_win })
vim.api.nvim_set_option_value('number', false, { win = S.list_win })
vim.api.nvim_set_option_value('relativenumber', false, { win = S.list_win })
vim.api.nvim_set_option_value('signcolumn', 'yes:1', { win = S.list_win })
vim.api.nvim_set_option_value('foldcolumn', '0', { win = S.list_win })
vim.api.nvim_set_option_value('winhighlight', list_win_hl, { win = S.list_win })
if S.file_info_win and vim.api.nvim_win_is_valid(S.file_info_win) then
vim.api.nvim_set_option_value('wrap', false, { win = S.file_info_win })
vim.api.nvim_set_option_value('cursorline', false, { win = S.file_info_win })
vim.api.nvim_set_option_value('number', false, { win = S.file_info_win })
vim.api.nvim_set_option_value('relativenumber', false, { win = S.file_info_win })
vim.api.nvim_set_option_value('signcolumn', 'no', { win = S.file_info_win })
vim.api.nvim_set_option_value('foldcolumn', '0', { win = S.file_info_win })
vim.api.nvim_set_option_value('winhighlight', file_info_win_hl, { win = S.file_info_win })
end
local picker_group = vim.api.nvim_create_augroup('fff_picker_focus', { clear = true })
local function is_picker_window(win)
if not win or not vim.api.nvim_win_is_valid(win) then return false end
local picker_windows = { S.input_win, S.list_win }
if S.preview_win then table.insert(picker_windows, S.preview_win) end
if S.file_info_win then table.insert(picker_windows, S.file_info_win) end
for _, picker_win in ipairs(picker_windows) do
if picker_win and vim.api.nvim_win_is_valid(picker_win) and win == picker_win then return true end
end
return false
end
vim.api.nvim_create_autocmd('WinLeave', {
group = picker_group,
callback = function()
if not P.state.active then return end
local leaving_win = vim.api.nvim_get_current_win()
if not is_picker_window(leaving_win) then return end
vim.schedule(function()
if not P.state.active then return end
local new_win = vim.api.nvim_get_current_win()
if not is_picker_window(new_win) then P.close() end
end)
end,
desc = 'Close picker when focus leaves picker windows',
})
vim.api.nvim_create_autocmd('VimResized', {
group = picker_group,
callback = function()
if not P.state.active then return end
vim.schedule(function()
if not P.state.active then return end
P.relayout()
end)
end,
desc = 'Re-layout picker on terminal resize',
})
end
function M.setup_keymaps()
local keymaps = S.config.keymaps
local input_opts = { buffer = S.input_buf, noremap = true, silent = true }
local list_opts = { buffer = S.list_buf, noremap = true, silent = true }
vim.keymap.set('i', '<C-w>', function()
local col = vim.fn.col('.') - 1
local line = vim.fn.getline('.')
local prompt_len = #S.config.prompt
if col <= prompt_len then return '' end
local text_part = line:sub(prompt_len + 1, col)
local after_cursor = line:sub(col + 1)
local new_text = text_part:gsub('%S*%s*$', '')
local new_line = S.config.prompt .. new_text .. after_cursor
local new_col = prompt_len + #new_text
vim.fn.setline('.', new_line)
vim.fn.cursor(vim.fn.line('.'), new_col + 1)
return ''
end, input_opts)
set_keymap({ 'n', 'i' }, keymaps.move_up, P.move_up, input_opts)
set_keymap({ 'n', 'i' }, keymaps.move_down, P.move_down, input_opts)
set_keymap('i', keymaps.cycle_previous_query, P.recall_query_from_history, input_opts)
set_keymap('i', keymaps.cycle_forward_query, P.cycle_forward_query, input_opts)
set_keymap('n', 'j', P.move_down, input_opts)
set_keymap('n', 'k', P.move_up, input_opts)
set_keymap('n', 'q', P.close, input_opts)
set_keymap('n', keymaps.focus_list, M.focus_list_win, input_opts)
set_keymap('n', keymaps.focus_preview, M.focus_preview_win, input_opts)
if keymaps.grep_jump_to_next_file then
set_keymap({ 'i', 'n' }, keymaps.grep_jump_to_next_file, function() P.grep_jump_to_next_file() end, input_opts)
end
if keymaps.grep_jump_to_prev_file then
set_keymap({ 'i', 'n' }, keymaps.grep_jump_to_prev_file, function() P.grep_jump_to_prev_file() end, input_opts)
end
if S.config.prompt_vim_mode then
set_keymap('n', keymaps.close, P.close, input_opts)
set_keymap('i', '<C-c>', P.close, input_opts)
-- cc/S clear the whole line, wiping the prompt buffer's prompt and leaving
-- a stray icon behind. Reset to just the prompt and re-enter insert instead.
local function clear_query_line()
local prompt = S.config.prompt
vim.api.nvim_set_option_value('modifiable', true, { buf = S.input_buf })
vim.api.nvim_buf_set_lines(S.input_buf, 0, -1, false, { prompt })
vim.schedule(function()
if S.input_win and vim.api.nvim_win_is_valid(S.input_win) then
vim.api.nvim_win_set_cursor(S.input_win, { 1, #prompt })
vim.cmd('startinsert!')
end
end)
end
-- remap=true so existing user mappings of cc/S still resolve
local clear_opts = vim.tbl_extend('force', input_opts, { noremap = false, remap = true })
set_keymap('n', 'cc', clear_query_line, clear_opts)
set_keymap('n', 'S', clear_query_line, clear_opts)
else
set_keymap({ 'i', 'n' }, keymaps.close, P.close, input_opts)
end
set_keymap({ 'i', 'n' }, keymaps.select, P.select, input_opts)
set_keymap({ 'i', 'n' }, keymaps.select_split, function() P.select('split') end, input_opts)
set_keymap({ 'i', 'n' }, keymaps.select_vsplit, function() P.select('vsplit') end, input_opts)
set_keymap({ 'i', 'n' }, keymaps.select_tab, function() P.select('tab') end, input_opts)
set_keymap({ 'i', 'n' }, keymaps.preview_scroll_up, P.scroll_preview_up, input_opts)
set_keymap({ 'i', 'n' }, keymaps.preview_scroll_down, P.scroll_preview_down, input_opts)
set_keymap({ 'i', 'n' }, keymaps.toggle_debug, P.toggle_debug, input_opts)
set_keymap({ 'i', 'n' }, keymaps.toggle_select, P.toggle_select, input_opts)
set_keymap({ 'i', 'n' }, keymaps.send_to_quickfix, P.send_to_quickfix, input_opts)
set_keymap({ 'i', 'n' }, keymaps.cycle_grep_modes, P.cycle_grep_modes, input_opts)
local input_mouse_opts = vim.tbl_extend('force', input_opts, { expr = true, replace_keycodes = true })
set_keymap(
{ 'i', 'n' },
'<LeftMouse>',
function() return handle_mouse_click_or_fallback(nil, '<LeftMouse>') end,
input_mouse_opts
)
set_keymap(
{ 'i', 'n' },
'<2-LeftMouse>',
function() return handle_mouse_click_or_fallback('edit', '<2-LeftMouse>') end,
input_mouse_opts
)
-- List buffer
set_keymap('n', keymaps.close, P.close, list_opts)
set_keymap('n', 'q', P.close, list_opts)
set_keymap('n', 'j', function() move_list_cursor(1) end, list_opts)
set_keymap('n', 'k', function() move_list_cursor(-1) end, list_opts)
set_keymap('n', 'i', M.focus_input_win, list_opts)
set_keymap('n', keymaps.focus_preview, M.focus_preview_win, list_opts)
set_keymap('n', keymaps.select, P.select, list_opts)
set_keymap('n', keymaps.select_split, function() P.select('split') end, list_opts)
set_keymap('n', keymaps.select_vsplit, function() P.select('vsplit') end, list_opts)
set_keymap('n', keymaps.select_tab, function() P.select('tab') end, list_opts)
set_keymap('n', keymaps.preview_scroll_up, P.scroll_preview_up, list_opts)
set_keymap('n', keymaps.preview_scroll_down, P.scroll_preview_down, list_opts)
set_keymap('n', keymaps.toggle_debug, P.toggle_debug, list_opts)
set_keymap('n', keymaps.toggle_select, P.toggle_select, list_opts)
set_keymap('n', keymaps.send_to_quickfix, P.send_to_quickfix, list_opts)
local list_mouse_opts = vim.tbl_extend('force', list_opts, { expr = true, replace_keycodes = true })
set_keymap(
'n',
'<LeftMouse>',
function() return handle_mouse_click_or_fallback(nil, '<LeftMouse>') end,
list_mouse_opts
)
set_keymap(
'n',
'<2-LeftMouse>',
function() return handle_mouse_click_or_fallback('edit', '<2-LeftMouse>') end,
list_mouse_opts
)
-- Preview buffer
if S.preview_buf then
local preview_opts = { buffer = S.preview_buf, noremap = true, silent = true }
set_keymap('n', keymaps.close, P.close, preview_opts)
set_keymap('n', 'q', P.close, preview_opts)
set_keymap('n', 'i', M.focus_input_win, preview_opts)
set_keymap('n', keymaps.focus_list, M.focus_list_win, preview_opts)
set_keymap('n', keymaps.select, P.select, preview_opts)
set_keymap('n', keymaps.select_split, function() P.select('split') end, preview_opts)
set_keymap('n', keymaps.select_vsplit, function() P.select('vsplit') end, preview_opts)
set_keymap('n', keymaps.select_tab, function() P.select('tab') end, preview_opts)
set_keymap('n', keymaps.toggle_debug, P.toggle_debug, preview_opts)
set_keymap('n', keymaps.toggle_select, P.toggle_select, preview_opts)
set_keymap('n', keymaps.send_to_quickfix, P.send_to_quickfix, preview_opts)
end
vim.api.nvim_buf_attach(S.input_buf, false, {
on_lines = function()
vim.schedule(function() P.on_input_change() end)
end,
})
if S.config.prompt_vim_mode then
vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, {
buffer = S.input_buf,
callback = function()
local prompt_len = #S.config.prompt
if vim.fn.col('.') <= prompt_len then vim.fn.cursor(vim.fn.line('.'), prompt_len + 1) end
end,
})
end
end
function M.focus_list_win()
if not P.state.active then return end
if not S.list_win or not vim.api.nvim_win_is_valid(S.list_win) then return end
vim.cmd('stopinsert')
vim.api.nvim_set_current_win(S.list_win)
end
function M.focus_preview_win()
if not P.state.active then return end
if not S.preview_win or not vim.api.nvim_win_is_valid(S.preview_win) then return end
vim.cmd('stopinsert')
vim.api.nvim_set_current_win(S.preview_win)
end
function M.focus_input_win()
if not P.state.active then return end
if not S.input_win or not vim.api.nvim_win_is_valid(S.input_win) then return end
vim.api.nvim_set_current_win(S.input_win)
vim.api.nvim_win_call(S.input_win, function() vim.cmd('startinsert!') end)
end
-- Expose open/close preview for relayout in the main module
M.open_preview = open_preview
M.close_preview = close_preview
return M
+97
View File
@@ -0,0 +1,97 @@
local M = {}
local picker_ui_state = require('fff.picker_ui.picker_ui_state')
local layout_manager = require('fff.picker_ui.layout_manager')
local utils = require('fff.utils')
local canonicalize_fff_path = utils.canonicalize_fff_path
--- Build quickfix list from selections and open it.
--- Handles close, setqflist, copen, and notification.
function M.send_to_quickfix()
local S = picker_ui_state.state
if not S.active then return end
local qf_list = {}
if S.mode == 'grep' then
local has_selections = next(S.selected_items) ~= nil
if has_selections then
for _, item in pairs(S.selected_items) do
local abs = canonicalize_fff_path(item.relative_path)
if abs then
table.insert(qf_list, {
filename = abs,
lnum = item.line_number or 1,
col = (item.col or 0) + 1,
text = item.line_content or vim.fn.fnamemodify(abs, ':.'),
})
end
end
else
local grep = require('fff.picker_ui.grep_renderer')
local exhaustive_config = vim.tbl_extend('force', S.grep_config or {}, { max_matches_per_file = 0 })
local exhaustive = grep.search(S.query, 0, 10000, exhaustive_config, S.grep_mode)
local all_items = exhaustive and exhaustive.items or {}
if #all_items == 0 then
vim.notify('No matches to send to quickfix', vim.log.levels.WARN)
return
end
for _, item in ipairs(all_items) do
local abs = canonicalize_fff_path(item.relative_path)
if abs then
table.insert(qf_list, {
filename = abs,
lnum = item.line_number or 1,
col = (item.col or 0) + 1,
text = item.line_content or vim.fn.fnamemodify(abs, ':.'),
})
end
end
end
else
local paths = {}
if next(S.selected_files) then
for relative_path, _ in pairs(S.selected_files) do
table.insert(paths, canonicalize_fff_path(relative_path))
end
else
for _, item in ipairs(S.filtered_items) do
local abs = canonicalize_fff_path(item.relative_path)
if abs then table.insert(paths, abs) end
end
end
if #paths == 0 then
vim.notify('No files to send to quickfix', vim.log.levels.WARN)
return
end
for _, path in ipairs(paths) do
table.insert(qf_list, {
filename = path,
lnum = 1,
col = 1,
text = vim.fn.fnamemodify(path, ':.'),
})
end
end
local is_grep = S.mode == 'grep'
-- Close picker, populate quickfix, open it
layout_manager.close()
vim.fn.setqflist(qf_list)
vim.cmd('copen')
local count = #qf_list
local unit = is_grep and (count == 1 and 'match' or 'matches') or (count == 1 and 'file' or 'files')
vim.notify(string.format('Added %d %s to quickfix list', count, unit), vim.log.levels.INFO)
end
return M
+76
View File
@@ -0,0 +1,76 @@
local download = require('fff.download')
local is_windows = jit.os:lower() == 'windows'
--- @return string
local function get_lib_extension()
if jit.os:lower() == 'mac' or jit.os:lower() == 'osx' then return '.dylib' end
if is_windows then return '.dll' end
return '.so'
end
--- Resolve a path to an absolute, clean form with native separators.
--- Resolves `..` components and on Windows converts forward slashes to
--- backslashes so that Windows APIs (LoadLibraryEx) can find the file.
--- @param path string
--- @return string
local function resolve_path(path)
local resolved = vim.fn.fnamemodify(path, ':p')
if is_windows then resolved = resolved:gsub('/', '\\') end
return resolved
end
-- Determine base_path from the location of this Lua file
local info = debug.getinfo(1, 'S')
-- Match both forward and backslash directory separators for cross-platform support
local base_path = info and info.source and info.source:match('@?(.*[/\\])') or ''
-- Fallback: if base_path is empty, use vim APIs
if not base_path or base_path == '' then
base_path = vim.fn.fnamemodify(vim.fn.resolve(vim.fn.expand('<sfile>:p')), ':h') .. '/'
end
local paths = {
download.get_binary_cpath_component(),
base_path .. '../../../target/release/lib?' .. get_lib_extension(),
base_path .. '../../../target/release/?' .. get_lib_extension(),
}
local cargo_target_dir = os.getenv('CARGO_TARGET_DIR')
if cargo_target_dir then
table.insert(paths, cargo_target_dir .. '/release/lib?' .. get_lib_extension())
table.insert(paths, cargo_target_dir .. '/release/?' .. get_lib_extension())
end
-- Instead of using require (which can find the wrong lib due to cpath pollution),
-- load the library directly from the first valid path we find
local function try_load_library()
for _, path_pattern in ipairs(paths) do
local actual_path = resolve_path(path_pattern:gsub('%?', 'fff_nvim'))
local stat = vim.uv.fs_stat(actual_path)
if stat and stat.type == 'file' then
local loader, err = package.loadlib(actual_path, 'luaopen_fff_nvim')
if err then return nil, string.format('Error loading library from %s: %s', actual_path, err) end
if loader then return loader() end
end
end
return nil, 'No valid library found in any search path'
end
local backend, load_err = try_load_library()
if not backend or load_err then
local resolved = {}
for _, p in ipairs(paths) do
table.insert(resolved, resolve_path(p:gsub('%?', 'fff_nvim')))
end
local err_msg = string.format(
'Failed to load fff rust backend.\nError: %s\nSearched paths:\n%s\nMake sure binary exists or make it exists using \n `:lua require("fff.download").download_or_build_binary()`\nor\n`cargo build --release`\n(and rerun neovim after)',
tostring(load_err),
vim.inspect(resolved)
)
error(err_msg)
end
return backend
+119
View File
@@ -0,0 +1,119 @@
--- Scrollbar module for pagination indicator
local M = {}
-- Internal state
local scrollbar_state = {
win = nil,
buf = nil,
ever_shown = false,
}
local ns_id = vim.api.nvim_create_namespace('fff_scrollbar')
--- Render the scrollbar to show current page position
--- Creates the window lazily if needed
--- @param layout table Layout info with list_col, list_row, list_width, list_height, show_scrollbar
--- @param config table Config with hl (highlight groups)
--- @param list_win number List window handle
--- @param pagination table Pagination state with page_index, page_size, total_matched
--- @param prompt_position string|nil Prompt position ('top' or 'bottom', defaults to 'bottom')
function M.render(layout, config, list_win, pagination, prompt_position)
if layout.show_scrollbar == false then return end
-- this is the most often path, we don't want to show scrollbar if use doesn't scrolling
if not scrollbar_state.ever_shown and pagination.page_index == 0 then return end
prompt_position = prompt_position or 'bottom'
local total_pages = pagination.page_size > 0 and math.ceil(pagination.total_matched / pagination.page_size) or 1
local has_multiple_pages = total_pages > 1
local scrollbar_exists = scrollbar_state.win and vim.api.nvim_win_is_valid(scrollbar_state.win)
-- If only one page, hide existing scrollbar and return
if not has_multiple_pages then
if scrollbar_exists then pcall(vim.api.nvim_win_hide, scrollbar_state.win) end
return
end
-- rendering in a separate buffer to overflow the border
if not scrollbar_exists then
scrollbar_state.buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scrollbar_state.buf })
scrollbar_state.win = vim.api.nvim_open_win(scrollbar_state.buf, false, {
relative = 'editor',
width = 1,
height = layout.list_height,
col = layout.list_col + layout.list_width + 1,
row = layout.list_row + 1,
border = 'none',
style = 'minimal',
focusable = false,
-- Float above the list/preview borders so the thumb is visible. Must
-- be higher than the list (52) and preview (51) zindex; 200 leaves
-- room for transient overlays (combo separator at 250 still wins).
zindex = 200,
})
local scrollbar_hl = string.format('Normal:%s', config.hl.border)
vim.api.nvim_set_option_value('winhighlight', scrollbar_hl, { win = scrollbar_state.win })
scrollbar_state.ever_shown = true
end
if not scrollbar_state.buf or not vim.api.nvim_buf_is_valid(scrollbar_state.buf) then return end
pcall(vim.api.nvim_win_set_config, scrollbar_state.win, { hide = false })
local win_height = vim.api.nvim_win_get_height(list_win)
local thumb_size = math.max(1, math.floor(win_height / total_pages))
local scrollbar_range = win_height - thumb_size
-- inverse the scrollbar when the position is at the bottom
local thumb_start
if prompt_position == 'bottom' then
thumb_start =
math.floor(((total_pages - 1 - pagination.page_index) / math.max(1, total_pages - 1)) * scrollbar_range)
else
thumb_start = math.floor((pagination.page_index / math.max(1, total_pages - 1)) * scrollbar_range)
end
local lines = {}
for i = 1, win_height do
if i >= thumb_start + 1 and i < thumb_start + thumb_size + 1 then
table.insert(lines, '') -- Thick block for thumb
else
table.insert(lines, '') -- Thin line for track
end
end
pcall(vim.api.nvim_set_option_value, 'modifiable', true, { buf = scrollbar_state.buf })
pcall(vim.api.nvim_buf_set_lines, scrollbar_state.buf, 0, -1, false, lines)
pcall(vim.api.nvim_set_option_value, 'modifiable', false, { buf = scrollbar_state.buf })
pcall(vim.api.nvim_buf_clear_namespace, scrollbar_state.buf, ns_id, 0, -1)
if thumb_size > 0 then
pcall(vim.api.nvim_buf_set_extmark, scrollbar_state.buf, ns_id, thumb_start, 0, {
end_row = thumb_start + thumb_size,
end_col = 0,
hl_group = config.hl.scrollbar,
hl_eol = true,
})
end
end
function M.cleanup()
if scrollbar_state.win and vim.api.nvim_win_is_valid(scrollbar_state.win) then
pcall(vim.api.nvim_win_close, scrollbar_state.win, true)
end
if scrollbar_state.buf and vim.api.nvim_buf_is_valid(scrollbar_state.buf) then
pcall(vim.api.nvim_buf_delete, scrollbar_state.buf, { force = true })
end
scrollbar_state.win = nil
scrollbar_state.buf = nil
scrollbar_state.ever_shown = false
end
return M
+111
View File
@@ -0,0 +1,111 @@
local utils = require('fff.utils')
local M = {}
--- Per-language scratch buffer cache
--- @type table<string, number>
local scratch_bufs = {}
--- Get or create a scratch buffer for a given treesitter language.
--- The buffer is reused across calls — content is overwritten each time.
--- @param lang string Treesitter language name
--- @return number buf Buffer handle
local function get_scratch_buf(lang)
local buf = scratch_bufs[lang]
if buf and vim.api.nvim_buf_is_valid(buf) then return buf end
buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_name(buf, 'fff://treesitter/' .. lang)
vim.bo[buf].bufhidden = 'hide'
vim.bo[buf].buftype = 'nofile'
vim.bo[buf].swapfile = false
vim.bo[buf].undolevels = -1
scratch_bufs[lang] = buf
return buf
end
--- Resolve a filename to a treesitter language.
--- Returns nil if no parser is available.
--- @param filename string File name (e.g. "foo.rs")
--- @return string|nil lang Treesitter language name, or nil
function M.lang_from_filename(filename)
if not filename or filename == '' then return nil end
local ft = utils.detect_filetype(filename) or 'text'
local lang_ok, lang = pcall(vim.treesitter.language.get_lang, ft)
if not lang_ok or not lang then lang = ft end
-- Check if the parser is actually installed
local has_parser = pcall(vim.treesitter.language.add, lang)
if not has_parser then return nil end
return lang
end
--- Extract treesitter highlights for a single line of code.
--- Returns an array of { col, end_col, hl_group } tables where col/end_col
--- are 0-based byte offsets within the input string.
---
--- @param text string The line of code to highlight
--- @param lang string Treesitter language name (from lang_from_filename)
--- @return table[] highlights Array of { col: number, end_col: number, hl_group: string }
function M.get_line_highlights(text, lang)
if not text or text == '' or not lang then return {} end
local buf = get_scratch_buf(lang)
-- Write the single line into the scratch buffer
vim.bo[buf].modifiable = true
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { text })
vim.bo[buf].modifiable = false
-- Parse with treesitter
local ok, parser = pcall(vim.treesitter.get_parser, buf, lang)
if not ok or not parser then return {} end
local parse_ok = pcall(parser.parse, parser, true)
if not parse_ok then return {} end
local highlights = {}
parser:for_each_tree(function(tstree, tree)
if not tstree then return end
local root = tstree:root()
if not root then return end
local tree_lang = tree:lang()
local query_ok, query = pcall(vim.treesitter.query.get, tree_lang, 'highlights')
if not query_ok or not query then return end
for capture, node, _ in query:iter_captures(root, buf, 0, 1) do
local name = query.captures[capture]
if name and name ~= 'spell' and name ~= 'conceal' then
local start_row, start_col, end_row, end_col = node:range()
-- Only process highlights on line 0 (our single line)
if start_row == 0 then
if end_row > 0 then end_col = #text end -- multi-line node: clamp to line end
if start_col < end_col then
highlights[#highlights + 1] = {
col = start_col,
end_col = end_col,
hl_group = '@' .. name .. '.' .. tree_lang,
}
end
end
end
end
end)
return highlights
end
--- Clean up all scratch buffers.
--- Called when the picker closes.
function M.cleanup()
for lang, buf in pairs(scratch_bufs) do
if buf and vim.api.nvim_buf_is_valid(buf) then pcall(vim.api.nvim_buf_delete, buf, { force = true }) end
scratch_bufs[lang] = nil
end
end
return M
+140
View File
@@ -0,0 +1,140 @@
local M = {}
--- Format file size into human-readable string
--- @param size number File size in bytes
--- @return string Formatted size string (e.g., "1.2 KB", "3.4 MB")
function M.format_file_size(size)
if not size or size < 0 then return 'Unknown' end
if size < 1024 then
return string.format('%d B', size)
elseif size < 1024 * 1024 then
return string.format('%.1f KB', size / 1024)
elseif size < 1024 * 1024 * 1024 then
return string.format('%.1f MB', size / (1024 * 1024))
else
return string.format('%.1f GB', size / (1024 * 1024 * 1024))
end
end
local function get_fixed_filetype_detection(extension)
local extension_map = {
ts = 'typescript',
tex = 'latex',
md = 'markdown',
txt = 'text',
}
return extension_map[extension]
end
--- Detect filetype with various fallbacks
--- @param file_path string the filetype
--- @return string detected filetype
function M.detect_filetype(file_path)
local has_plenary, plenary_filetype = pcall(require, 'plenary.filetype')
if has_plenary then
local detected = plenary_filetype.detect(file_path, {})
if detected and detected ~= '' then return detected end
end
local builtin_filetype = vim.filetype.match({ filename = file_path })
if builtin_filetype and builtin_filetype ~= '' then return builtin_filetype end
local extension = vim.fn.fnamemodify(file_path, ':e'):lower()
return get_fixed_filetype_detection(extension)
end
--- Safely resolve a config value that can be either a static value or a function
--- @param config_value any The config value (can be function or static value)
--- @param terminal_width number Terminal width for function calls
--- @param terminal_height number Terminal height for function calls
--- @param validator function Function to validate the result
--- @param fallback any Fallback value if function fails or returns invalid value
--- @param error_context string Context for error messages
--- @return number The resolved and validated value
function M.resolve_config_value(config_value, terminal_width, terminal_height, validator, fallback, error_context)
if type(config_value) == 'function' then
local success, result = pcall(config_value, terminal_width, terminal_height)
if success and validator(result) then
return result
else
if not success then
vim.notify('FFF: Error in ' .. error_context .. ' function: ' .. tostring(result), vim.log.levels.WARN)
end
return fallback
end
else
if config_value == nil or not validator(config_value) then return fallback end
return config_value
end
end
--- Validate numeric ratio (0 < value <= 1)
--- @param value any Value to validate
--- @return boolean True if valid numeric ratio
function M.is_valid_ratio(value) return type(value) == 'number' and value > 0 and value <= 1 end
--- Validate position string
--- @param value any Value to validate
--- @param values table List of valid values strings
--- @return boolean True if valid position
function M.is_one_of(value, values)
if type(value) ~= 'string' then return false end
for _, pos in ipairs(values) do
if value == pos then return true end
end
return false
end
--- Resolve an indexer-relative path to an absolute one against the picker's current `base_path`.
--- @param relative_path string|nil
--- @return string|nil
function M.canonicalize_fff_path(relative_path)
if not relative_path or relative_path == '' then return nil end
local path = relative_path
-- Strip Windows long-path prefix (\\?\) — Neovim cannot open these.
if vim.startswith(path, '\\\\?\\') then path = path:sub(5) end
if vim.fn.fnamemodify(path, ':p') == path then return path end
local base = require('fff.conf').get().base_path
if not base or base == '' then return path end
return vim.fs.normalize(base .. '/' .. path)
end
--- Whether a window has `winfixbuf` set (cannot host a different buffer).
--- @param win number Window ID
--- @return boolean
function M.window_has_winfixbuf(win)
local ok, val = pcall(vim.api.nvim_get_option_value, 'winfixbuf', { win = win })
return ok and val == true
end
--- Find the first window in the current tabpage that can host a regular file
--- buffer (writable, not locked, not the picker's own floats).
--- @param exclude_wins? table<number, boolean> Optional set of window IDs to skip.
--- @return number|nil
function M.find_suitable_window(exclude_wins)
exclude_wins = exclude_wins or {}
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(vim.api.nvim_get_current_tabpage())) do
if vim.api.nvim_win_is_valid(win) and not exclude_wins[win] then
local buf = vim.api.nvim_win_get_buf(win)
if vim.api.nvim_buf_is_valid(buf) then
local buftype = vim.api.nvim_get_option_value('buftype', { buf = buf })
local modifiable = vim.api.nvim_get_option_value('modifiable', { buf = buf })
local filetype = vim.api.nvim_get_option_value('filetype', { buf = buf })
if
(buftype == '' or buftype == 'acwrite')
and modifiable
and filetype ~= 'undotree'
and not M.window_has_winfixbuf(win)
then
return win
end
end
end
end
return nil
end
return M
+33
View File
@@ -0,0 +1,33 @@
local M = {}
function M.mkdir_recursive(path, callback)
vim.uv.fs_stat(path, function(err, stat)
if not err and stat then
callback(true, nil)
return
end
local parent = vim.fn.fnamemodify(path, ':h')
if parent == path or parent == '' or parent == '.' then
callback(false, 'Cannot create root directory')
return
end
M.mkdir_recursive(parent, function(parent_ok, parent_err)
if not parent_ok then
callback(false, parent_err)
return
end
vim.uv.fs_mkdir(path, 493, function(mkdir_err) -- 493 = 0755 octal
if mkdir_err and not mkdir_err:match('EEXIST') then
callback(false, 'Failed to create directory: ' .. mkdir_err)
return
end
callback(true, nil)
end)
end)
end)
end
return M
+61
View File
@@ -0,0 +1,61 @@
local M = {}
local uv = vim and vim.uv or require('luv')
-- Get the system triple (target triple for the current platform)
function M.get_triple()
local os_name = uv.os_uname().sysname:lower()
local arch = uv.os_uname().machine:lower()
-- Normalize OS name
if os_name == 'darwin' then
os_name = 'apple-darwin'
elseif os_name == 'linux' then
-- Detect Android/Termux before checking musl/glibc.
-- Termux uses Bionic libc (not glibc or musl) and has no ldd.
if os.getenv('TERMUX_VERSION') or os.getenv('ANDROID_ROOT') then
os_name = 'linux-android'
else
-- Detect if we're on musl or glibc
local handle = io.popen('ldd --version 2>&1')
if handle then
local output = handle:read('*a')
handle:close()
if output and output:match('musl') then
os_name = 'unknown-linux-musl'
else
os_name = 'unknown-linux-gnu'
end
else
os_name = 'unknown-linux-gnu'
end
end
elseif os_name:match('windows') or os_name:match('mingw') or os_name:match('msys') then
os_name = 'pc-windows-msvc'
end
-- Normalize architecture
if arch == 'x86_64' or arch == 'amd64' then
arch = 'x86_64'
elseif arch == 'aarch64' or arch == 'arm64' then
arch = 'aarch64'
elseif arch:match('^arm') then
arch = 'arm'
end
return arch .. '-' .. os_name
end
-- Get the library extension for the current platform
function M.get_lib_extension()
local os_name = uv.os_uname().sysname:lower()
if os_name == 'darwin' then
return 'dylib'
elseif os_name:match('windows') or os_name:match('mingw') or os_name:match('msys') then
return 'dll'
else
return 'so'
end
end
return M
+156
View File
@@ -0,0 +1,156 @@
local M = {}
local is_windows = (package.config:sub(1, 1) == '\\')
--- Shell-quote a string for safe interpolation into a command.
---@param s string
---@return string
local function shell_quote(s)
if is_windows then return '"' .. s:gsub('"', '\\"') .. '"' end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
--- Run a git command in the given repository and return trimmed stdout.
---@param repo_root string
---@return string|nil output trimmed stdout, or nil on failure
local function git(repo_root, ...)
local parts = { 'git', '-C', shell_quote(repo_root) }
for i = 1, select('#', ...) do
parts[#parts + 1] = shell_quote(select(i, ...))
end
local redirect = is_windows and ' 2>NUL' or ' 2>/dev/null'
local handle = io.popen(table.concat(parts, ' ') .. redirect)
if not handle then return nil end
local output = handle:read('*a')
handle:close()
if not output or output:match('^%s*$') then return nil end
return output:gsub('%s+$', '')
end
function M.current_release_tag(repo_root)
local raw = git(repo_root, 'tag', '--points-at', 'HEAD')
if not raw then return nil end
-- A nightly commit carries both the per-sha tag (e.g. 0.9.2-nightly.abc1234,
-- which owns a permanent release with that commit's binaries) and the rolling
-- `nightly` alias. Prefer the per-sha tag so a pinned install downloads the
-- binary built for its own commit, not whatever `nightly` currently points at.
local stable, nightly_versioned, nightly_alias, dev, other
for tag in raw:gmatch('[^\n]+') do
if tag:match('^v%d') then
stable = tag
elseif tag:match('%-nightly%.') then
nightly_versioned = tag
elseif tag == 'nightly' then
nightly_alias = tag
elseif tag:match('%-dev%.') then
dev = tag
else
other = tag
end
end
return stable or nightly_versioned or dev or other or nightly_alias
end
function M.read_base_version(repo_root)
local cargo_path = repo_root .. '/crates/fff-core/Cargo.toml'
local f = io.open(cargo_path, 'r')
if not f then return nil end
for line in f:lines() do
local ver = line:match('^version%s*=%s*"([^"]+)"')
if ver then
f:close()
return ver
end
end
f:close()
return nil
end
---@class FFFVersionInfo
---@field version string semver version (e.g. "0.4.0" or "0.4.1-nightly.abc1234")
---@field release_tag string permanent GitHub release tag clients download from (per-sha for nightlies)
---@field is_release boolean true for tagged stable releases
---@field npm_tag string "latest"|"nightly"|"dev"
--- Bump the patch component of a semver string.
--- "1.2.3" → "1.2.4"
---@param version string
---@return string|nil bumped version, or nil if parsing fails
local function bump_patch(version)
local major, minor, patch = version:match('^(%d+)%.(%d+)%.(%d+)')
if not major then return nil end
return string.format('%s.%s.%d', major, minor, tonumber(patch) + 1)
end
--- Compute the version for a new release based on git state.
--- Used by CI to determine what tag to create — NOT for downloads.
---
--- For prerelease versions the patch is bumped so that the result is
--- higher than the current Cargo.toml version in semver ordering.
--- This is required for `cargo set-version` / crates.io publishing
--- (0.4.1-nightly.x > 0.4.0, whereas 0.4.0-nightly.x < 0.4.0).
---
--- tagged release (v*) → version from tag, npm_tag = "latest"
--- main branch → {base+1}-nightly.{sha}, npm_tag = "nightly"
--- detached HEAD → {base+1}-nightly.{sha}, npm_tag = "nightly"
--- other branch (PR / feature) → {base+1}-dev.{sha}, npm_tag = "dev"
---
---@param repo_root string absolute path to the repository root
---@return FFFVersionInfo|nil info
---@return string|nil err
function M.resolve(repo_root)
local tag = git(repo_root, 'describe', '--exact-match', '--tags', '--match', 'v*', 'HEAD')
if tag and tag:match('^v%d') then
return {
version = tag:sub(2),
release_tag = tag,
is_release = true,
npm_tag = 'latest',
}
end
local short_sha = git(repo_root, 'rev-parse', '--short', 'HEAD')
if not short_sha then return nil, 'Failed to determine git SHA' end
local base_version = M.read_base_version(repo_root)
if not base_version then return nil, 'Could not read base version from crates/fff-core/Cargo.toml' end
local next_version = bump_patch(base_version)
if not next_version then return nil, 'Could not parse base version: ' .. base_version end
local branch = git(repo_root, 'symbolic-ref', '--short', 'HEAD')
local prerelease_label, npm_tag
if not branch then
prerelease_label = 'nightly'
npm_tag = 'nightly'
elseif branch == 'main' or branch == 'fix/download-version' then
prerelease_label = 'nightly'
npm_tag = 'nightly'
else
prerelease_label = 'dev'
npm_tag = 'dev'
end
local version = string.format('%s-%s.%s', next_version, prerelease_label, short_sha)
-- Each nightly gets its own permanent per-sha release/tag so a pinned or stale
-- install always downloads the binary built for its exact commit. CI also moves
-- the rolling `nightly` tag to HEAD for "give me latest" tooling, but clients
-- never resolve to it when their commit has a per-sha tag.
return {
version = version,
release_tag = version,
is_release = false,
npm_tag = npm_tag,
}
end
return M