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
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:
@@ -0,0 +1 @@
|
||||
return require('fff.main')
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user