Compare commits

...

3 Commits

Author SHA1 Message Date
Dmitriy Kovalenko 4a7cb312e1 feat: Correctly handle change of cwd automatically and via API
closes https://github.com/dmtrKovalenko/fff.nvim/issues/20

This allows to use

```
require('fff.main').find_files_in_dir('/Users/neogoose/dev/lightsource')
```

to change cwd and start the picker immediately to choose the file.
2025-08-02 23:44:28 +02:00
Dmitriy Kovalenko 422182b037 chore(docs): Remove duplicated section
Co-authored-by: Amalia Haible <florian@haible.de>
2025-08-02 23:11:35 +02:00
Dmitriy Kovalenko 3577a8ab3d fix: Remove default keymaps
Co-authored-by: Amalia Haible <florian@haible.de>
2025-08-02 23:08:34 +02:00
5 changed files with 101 additions and 183 deletions
+4 -52
View File
@@ -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
},
})
```
+1
View File
@@ -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
View File
@@ -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
-1
View File
@@ -1021,7 +1021,6 @@ function M.close()
end
end
--- Open the picker
function M.open(opts)
if M.state.active then return end
+35
View File
@@ -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(