Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a7cb312e1 | |||
| 422182b037 | |||
| 3577a8ab3d |
@@ -15,7 +15,7 @@
|
||||
<img alt="Contributors" src="https://img.shields.io/github/contributors/dmtrKovalenko/fff.nvim?color=%23DDB6F2&label=CONTRIBUTORS&logo=git&style=for-the-badge&logoColor=D9E0EE&labelColor=302D41"/></a>
|
||||
</p>
|
||||
|
||||
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an opinionated fuzzy file picker for neovim. Just for files, but we'll try to solve file picking completely.
|
||||
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an opinionated fuzzy file picker for neovim. Just for files, but we'll try to solve file picking completely.
|
||||
|
||||
It comes with a dedicated rust backend runtime that keep tracks of the file index, your file access and modifications, git status, and provides a comprehensive typo-resistant fuzzy search experience.
|
||||
|
||||
@@ -64,7 +64,6 @@ FFF.nvim requires:
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Default Configuration
|
||||
|
||||
FFF.nvim comes with sensible defaults. Here's the complete default configuration:
|
||||
@@ -85,15 +84,15 @@ require("fff").setup({
|
||||
max_results = 60, -- Maximum search results to display
|
||||
max_threads = 4, -- Maximum threads for fuzzy search
|
||||
|
||||
-- Key mappings (supports both single keys and arrays for multiple bindings)
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
move_up = { '<Up>', '<C-p>' }, -- Multiple bindings supported
|
||||
move_down = { '<Down>', '<C-n>' }, -- Multiple bindings supported
|
||||
-- Multiple bindings supported
|
||||
move_up = { '<Up>', '<C-p>' },
|
||||
move_down = { '<Down>', '<C-n>' },
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
},
|
||||
@@ -152,50 +151,3 @@ Toggle scoring information display:
|
||||
Plug 'MunifTanjim/nui.nvim'
|
||||
Plug 'dmtrKovalenko/fff.nvim', { 'do': 'cargo build --release' }
|
||||
````
|
||||
|
||||
## Configuration
|
||||
|
||||
### Default Configuration
|
||||
|
||||
FFF.nvim comes with sensible defaults. Here's the complete default configuration:
|
||||
|
||||
```lua
|
||||
require("fff").setup({
|
||||
-- UI dimensions and appearance
|
||||
width = 0.8, -- Window width as fraction of screen
|
||||
height = 0.8, -- Window height as fraction of screen
|
||||
preview_width = 0.5, -- Preview pane width as fraction of picker
|
||||
prompt = '🪿 ', -- Input prompt symbol
|
||||
title = 'FFF Files', -- Window title
|
||||
max_results = 60, -- Maximum search results to display
|
||||
max_threads = 4, -- Maximum threads for fuzzy search
|
||||
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
move_up = { '<Up>', '<C-p>' }, -- Multiple bindings supported
|
||||
move_down = { '<Down>', '<C-n>' }, -- Multiple bindings supported
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
},
|
||||
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
},
|
||||
|
||||
debug = {
|
||||
show_scores = true, -- We hope for your collaboratio
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
|
||||
+61
-130
@@ -95,7 +95,6 @@ function M.setup(config)
|
||||
M.state.initialized = true
|
||||
M.config = merged_config
|
||||
|
||||
M.setup_default_keymaps()
|
||||
M.setup_commands()
|
||||
|
||||
if merged_config.frecency.enabled then M.setup_global_file_tracking() end
|
||||
@@ -104,7 +103,6 @@ function M.setup(config)
|
||||
git_utils.setup_highlights()
|
||||
|
||||
if merged_config.logging.enabled then
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
local log_success, log_error =
|
||||
pcall(fuzzy.init_tracing, merged_config.logging.log_file, merged_config.logging.log_level)
|
||||
if log_success then
|
||||
@@ -117,14 +115,6 @@ function M.setup(config)
|
||||
return true
|
||||
end
|
||||
|
||||
--- Setup default keymaps
|
||||
function M.setup_default_keymaps()
|
||||
vim.keymap.set('n', '<leader>ff', function() M.find_files() end, { desc = 'Find files' })
|
||||
vim.keymap.set('n', '<leader>ft', function() M.toggle() end, { desc = 'Toggle file picker' })
|
||||
vim.keymap.set('n', '<leader>fg', function() M.find_in_git_root() end, { desc = 'Find files in git root' })
|
||||
vim.keymap.set('n', '<leader>fr', function() M.find_recent() end, { desc = 'Find recent files' })
|
||||
end
|
||||
|
||||
function M.setup_global_file_tracking()
|
||||
local group = vim.api.nvim_create_augroup('fff_file_tracking', { clear = true })
|
||||
|
||||
@@ -138,7 +128,6 @@ function M.setup_global_file_tracking()
|
||||
vim.schedule(function()
|
||||
local stat = vim.loop.fs_stat(file_path)
|
||||
if stat and stat.type == 'file' then
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
local relative_path = vim.fn.fnamemodify(file_path, ':.')
|
||||
pcall(fuzzy.access_file, relative_path)
|
||||
end
|
||||
@@ -147,6 +136,19 @@ function M.setup_global_file_tracking()
|
||||
end,
|
||||
desc = 'Track file access for FFF frecency',
|
||||
})
|
||||
|
||||
-- Auto-sync directory changes with FFF file picker
|
||||
vim.api.nvim_create_autocmd('DirChanged', {
|
||||
group = group,
|
||||
callback = function()
|
||||
-- Use v:event.cwd to get the new current working directory
|
||||
local new_cwd = vim.v.event.cwd
|
||||
if M.is_initialized() and new_cwd and new_cwd ~= M.config.base_path then
|
||||
vim.schedule(function() M.changed_indexing_directory(new_cwd) end)
|
||||
end
|
||||
end,
|
||||
desc = 'Automatically sync FFF directory changes',
|
||||
})
|
||||
end
|
||||
|
||||
function M.setup_commands()
|
||||
@@ -164,7 +166,7 @@ function M.setup_commands()
|
||||
end
|
||||
end, {
|
||||
nargs = '?',
|
||||
complete = function(arg_lead, cmd_line, cursor_pos)
|
||||
complete = function(arg_lead)
|
||||
-- Complete with directories and common search terms
|
||||
local dirs = vim.fn.glob(arg_lead .. '*', false, true)
|
||||
local results = {}
|
||||
@@ -186,7 +188,7 @@ function M.setup_commands()
|
||||
|
||||
vim.api.nvim_create_user_command('FFFClearCache', function(opts) M.clear_cache(opts.args) end, {
|
||||
nargs = '?',
|
||||
complete = function(arg_lead, cmd_line, cursor_pos) return { 'all', 'frecency', 'files' } end,
|
||||
complete = function() return { 'all', 'frecency', 'files' } end,
|
||||
desc = 'Clear FFF caches (all|frecency|files)',
|
||||
})
|
||||
|
||||
@@ -238,20 +240,7 @@ function M.find_files()
|
||||
end
|
||||
end
|
||||
|
||||
--- Find files in specific directory
|
||||
--- @param dir string Directory path
|
||||
function M.find_files_in_dir(dir)
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then
|
||||
picker_ui.open({ cwd = dir })
|
||||
else
|
||||
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
|
||||
end
|
||||
end
|
||||
|
||||
--- Find files in git repository root
|
||||
function M.find_in_git_root()
|
||||
-- Check if we're in a git repo first
|
||||
local git_root = vim.fn.system('git rev-parse --show-toplevel 2>/dev/null'):gsub('\n', '')
|
||||
if vim.v.shell_error ~= 0 then
|
||||
vim.notify('Not in a git repository', vim.log.levels.WARN)
|
||||
@@ -266,22 +255,8 @@ function M.find_in_git_root()
|
||||
end
|
||||
end
|
||||
|
||||
--- Find recent files (frecency based)
|
||||
function M.find_recent()
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then
|
||||
picker_ui.open({ title = 'Recent Files' })
|
||||
else
|
||||
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
|
||||
end
|
||||
end
|
||||
|
||||
--- Toggle file picker
|
||||
function M.toggle() M.find_files() end
|
||||
|
||||
--- Scan files
|
||||
--- Trigger rescan of files in the current directory
|
||||
function M.scan_files()
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
local ok = pcall(fuzzy.scan_files)
|
||||
if ok then
|
||||
local cached_files = pcall(fuzzy.get_cached_files) and fuzzy.get_cached_files() or {}
|
||||
@@ -291,9 +266,8 @@ function M.scan_files()
|
||||
end
|
||||
end
|
||||
|
||||
--- Refresh git status for all cached files
|
||||
--- Refresh git status for the active file lock
|
||||
function M.refresh_git_status()
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
local ok, files = pcall(fuzzy.refresh_git_status)
|
||||
if ok then
|
||||
print('Refreshed git status for ' .. #files .. ' files')
|
||||
@@ -307,7 +281,6 @@ end
|
||||
--- @param max_results number Maximum number of results
|
||||
--- @return table List of matching files
|
||||
function M.search(query, max_results)
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
max_results = max_results or M.config.max_results
|
||||
local ok, search_result = pcall(fuzzy.fuzzy_search_files, query, max_results, nil, nil)
|
||||
if ok and search_result.items then return search_result.items end
|
||||
@@ -371,97 +344,12 @@ function M.get_preview(file_path)
|
||||
return table.concat(lines, '\n')
|
||||
end
|
||||
|
||||
function M.debug_file_ordering()
|
||||
print('FFF Debug File Ordering')
|
||||
print('=======================')
|
||||
|
||||
if not M.is_initialized() then
|
||||
print('File picker not initialized. Run :FFFScan first.')
|
||||
return
|
||||
end
|
||||
|
||||
local picker = M.picker or M.core
|
||||
print('Getting top 10 files with debug info...')
|
||||
|
||||
-- Enable debug mode temporarily
|
||||
local old_debug = M.config.debug.show_scores
|
||||
M.config.debug.show_scores = true
|
||||
|
||||
-- Search with empty query to get default ordering
|
||||
local files = picker.search_files('', 10)
|
||||
|
||||
print('🏆 TOP FILES (in order they appear):')
|
||||
print('=' .. string.rep('=', 70))
|
||||
|
||||
for i, file in ipairs(files) do
|
||||
local frecency_stars = ''
|
||||
if file.frecency_score > 0 then frecency_stars = ' ⭐' .. file.frecency_score end
|
||||
|
||||
-- Extract directory information
|
||||
local dir = vim.fn.fnamemodify(file.relative_path, ':h')
|
||||
local filename = vim.fn.fnamemodify(file.relative_path, ':t')
|
||||
local dir_display = (dir == '.' or dir == '') and 'root' or dir
|
||||
|
||||
-- Get score information for this file
|
||||
local score = picker.get_file_score(i)
|
||||
|
||||
print(string.format('%2d. %s%s', i, filename, frecency_stars))
|
||||
print(string.format(' Path: %s/', dir_display))
|
||||
print(string.format(' Debug: %s', score and score.match_type or 'no debug info'))
|
||||
if score then
|
||||
print(
|
||||
string.format(
|
||||
' Total Score: %d (base=%d, name_bonus=%d, special_bonus=%d, frec=%d, dist=%d)',
|
||||
score.total,
|
||||
score.base_score,
|
||||
score.filename_bonus,
|
||||
score.special_filename_bonus,
|
||||
score.frecency_boost,
|
||||
score.distance_penalty
|
||||
)
|
||||
)
|
||||
else
|
||||
print(' Total Score: N/A (no score data)')
|
||||
end
|
||||
|
||||
local now = os.time()
|
||||
local age_hours = math.floor((now - file.modified) / 3600)
|
||||
local age_days = math.floor(age_hours / 24)
|
||||
print(string.format(' Age: %d hours (%d days) since last modified', age_hours, age_days))
|
||||
print('')
|
||||
end
|
||||
|
||||
print('💡 EXPLANATION:')
|
||||
print('• Files are sorted by FRECENCY first (⭐ score), then by modification time')
|
||||
print('• Frecency combines how often AND how recently you accessed files')
|
||||
print('• The file at #1 has either:')
|
||||
print(' - Highest frecency score, OR')
|
||||
print(' - Same frecency as others but most recent modification')
|
||||
|
||||
M.config.debug.show_scores = old_debug
|
||||
end
|
||||
|
||||
function M.health_check()
|
||||
local health = {
|
||||
ok = true,
|
||||
messages = {},
|
||||
}
|
||||
|
||||
local errors = check_dependencies()
|
||||
if #errors > 0 then
|
||||
health.ok = false
|
||||
for _, error in ipairs(errors) do
|
||||
table.insert(health.messages, error)
|
||||
end
|
||||
end
|
||||
|
||||
local ui_errors = check_ui_dependencies()
|
||||
if #ui_errors > 0 then
|
||||
table.insert(health.messages, 'UI not available: ' .. table.concat(ui_errors, ', '))
|
||||
else
|
||||
table.insert(health.messages, '✓ UI available')
|
||||
end
|
||||
|
||||
if not M.is_initialized() then
|
||||
health.ok = false
|
||||
table.insert(health.messages, 'File picker not initialized')
|
||||
@@ -503,7 +391,6 @@ end
|
||||
function M.get_status()
|
||||
local status = 'No files indexed'
|
||||
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
local ok, cached_files = pcall(fuzzy.get_cached_files)
|
||||
if ok and cached_files and #cached_files > 0 then status = string.format('%d files indexed', #cached_files) end
|
||||
|
||||
@@ -516,4 +403,48 @@ end
|
||||
|
||||
function M.is_initialized() return M.state and M.state.initialized or false 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
|
||||
|
||||
M.changed_indexing_directory(directory)
|
||||
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then
|
||||
picker_ui.open({ title = 'Files in ' .. vim.fn.fnamemodify(directory, ':t') })
|
||||
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.changed_indexing_directory(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 ok, result = pcall(fuzzy.restart_index_in_path, expanded_path)
|
||||
if not ok then
|
||||
vim.notify('Failed to change directory: ' .. result, vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
M.config.base_path = expanded_path
|
||||
return true
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -1021,7 +1021,6 @@ function M.close()
|
||||
end
|
||||
end
|
||||
|
||||
--- Open the picker
|
||||
function M.open(opts)
|
||||
if M.state.active then return end
|
||||
|
||||
|
||||
@@ -46,6 +46,37 @@ pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn reinit_file_picker_internal(path: std::path::PathBuf) -> Result<(), Error> {
|
||||
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
|
||||
// drop should clean it anyway but just to be extra sure
|
||||
if let Some(picker) = file_picker.take() {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
|
||||
let new_picker = FilePicker::new(path.to_string_lossy().to_string())?;
|
||||
*file_picker = Some(new_picker);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<bool> {
|
||||
let path = std::path::PathBuf::from(&new_path);
|
||||
if !path.exists() {
|
||||
return Err(LuaError::RuntimeError(format!(
|
||||
"Path does not exist: {}",
|
||||
new_path
|
||||
)));
|
||||
}
|
||||
|
||||
let canonical_path = path.canonicalize().map_err(|e| {
|
||||
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
|
||||
})?;
|
||||
|
||||
reinit_file_picker_internal(canonical_path)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
@@ -163,6 +194,10 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
exports.set("init_db", lua.create_function(init_db)?)?;
|
||||
exports.set("destroy_db", lua.create_function(destroy_db)?)?;
|
||||
exports.set("init_file_picker", lua.create_function(init_file_picker)?)?;
|
||||
exports.set(
|
||||
"restart_index_in_path",
|
||||
lua.create_function(restart_index_in_path)?,
|
||||
)?;
|
||||
exports.set("scan_files", lua.create_function(scan_files)?)?;
|
||||
exports.set("get_cached_files", lua.create_function(get_cached_files)?)?;
|
||||
exports.set(
|
||||
|
||||
Reference in New Issue
Block a user