Why I use two different file pickers in Neovim
fff.nvim is fast and frecency-aware but opinionated about git repos. Snacks.picker covers what fff skips: dotfiles, non-git directories, everything. I wired both into the LazyVim dashboard.
I use fff.nvim for code projects and snacks.picker for everything else. They solve different problems, so I bound them to different entry points on the LazyVim dashboard.
Picker constraints
LazyVim’s default dashboard maps f to Snacks.dashboard.pick('files'). That opens snacks.picker, which is a general-purpose fuzzy file finder. It works everywhere, git repos, home directories, dotfiles, and has a hidden toggle that I already keep set to true.
But for code projects I wanted something faster. fff.nvim builds an in-memory index of every file in the repo, updates it in real time with a background filesystem watcher, and ranks results by frecency (how often and recently I’ve opened each file). The index makes repeated searches instant. The ranking puts the files I actually touch at the top. That matters when a project has a gazillion of files and I’m jumping between the same 30.
I also wanted fff on the dashboard for muscle memory; hitting f to open the fastest file finder felt right. But I didn’t want to lose snacks.picker’s ability to search dotfiles, non-git directories, and everything else.
Split workflow
I created a dashboard override in ~/.config/nvim/lua/plugins/dashboard.lua that replaces the preset.keys table in snacks.nvim’s config. The f key now opens fff, the g key opens fff’s live grep, and everything else stays as LazyVim defined it:
-- ~/.config/nvim/lua/plugins/dashboard.lua
return {
{
"folke/snacks.nvim",
opts = {
dashboard = {
preset = {
---@type snacks.dashboard.Item[]
keys = {
{ icon = " ", key = "f", desc = "Find File", action = ":lua _G.fff_find_files()" },
{ icon = " ", key = "n", desc = "New File", action = ":ene | startinsert" },
{ icon = " ", key = "g", desc = "Find Text", action = ":lua _G.fff_live_grep()" },
{ icon = " ", key = "r", desc = "Recent Files", action = ":lua Snacks.dashboard.pick('oldfiles')" },
{ icon = " ", key = "c", desc = "Config", action = ":lua Snacks.dashboard.pick('files', {cwd = vim.fn.stdpath('config')})" },
{ icon = " ", key = "s", desc = "Restore Session", section = "session" },
{ icon = " ", key = "x", desc = "Lazy Extras", action = ":LazyExtras" },
{ icon = " ", key = "l", desc = "Lazy", action = ":Lazy" },
{ icon = " ", key = "q", desc = "Quit", action = ":qa" },
},
},
},
},
},
}
LazyVim’s dashboard uses snacks.nvim under the hood. snacks.nvim deep-merges multiple plugin specs. LazyVim defines the base dashboard keys, and this override replaces them. The function references (_G.fff_find_files) are exposed as globals in my fff.lua plugin file so the dashboard can call them.
For the all-files search I kept LazyVim’s default <leader><leader> mapping; it still opens snacks.picker with hidden = true, which catches dotfiles, home directory browsing, and anything fff won’t touch.
The real difference: indexed vs. walked
fff.nvim builds a persistent, in-memory index of every file in the current directory tree. A background thread continuously watches the filesystem and updates the index. When you run a search, you’re querying that index, not walking the disk. This means:
- First search is slow (the index has to build), but every search after that is nearly instant.
- Frecency scoring works because the index stores file access history. Files you open frequently climb to the top.
- Git-aware sorting: staged, modified, and untracked files are tagged so you see what’s actively changing.
- Content indexing builds a bigram index for type-ahead suggestions.
The tradeoff: fff is opinionated about where it runs. In git repos, it shows everything (git tracking provides granular control over what to index). Outside git repos, it hides dotfiles entirely. The Rust source makes this explicit: the walker builder at crates/fff-core/src/file_picker.rs#L1791-L1792 sets:
// this is a very important guard for the user opening ~/ or other root non-git dir
.hidden(!is_git_repo)
The is_git_repo flag comes from line 1786: let is_git_repo = git_workdir.is_some();. When git is detected, the hidden filter is disabled; git tracking handles what to include. When git is absent, dotfiles are blocked entirely to prevent indexing garbage directories like .cache and .local.
snacks.picker takes the opposite approach. It walks the filesystem on demand and exposes a hidden flag directly in Lua. If you want dotfiles, you set hidden = true. No index, no watcher, no frecency; just a fast walk per invocation. This is the right tool for browsing directories where you don’t want the overhead of maintaining an index, or where you need to see files fff won’t show.
The keymap split
Here’s how I think about which picker to reach for:
fff.nvim (direct keys, no leader prefix):
| Key | Action |
|---|---|
ff | Find files |
fg | Live grep |
fz | Fuzzy grep |
fc | Grep current word |
Dashboard f | Find file |
Dashboard g | Find text |
snacks.picker (LazyVim defaults):
| Key | Action |
|---|---|
<leader><leader> | Find files (root dir) |
<leader>ff | Find files (root dir) |
<leader>fF | Find files (cwd) |
<leader>/ | Grep (root dir) |
<leader>fe | Explorer (root dir) |
<leader>fE | Explorer (cwd) |
<leader>gG | Lazygit |
The dashboard keys (f and g) are the daily ones for code projects. The fff direct keys (ff, fg) are the same operation available from anywhere in the editor. <leader><leader> is the escape hatch for when I’m somewhere fff won’t go.
Daily behavior
The dashboard maps each key to the right tool for the context. Hitting f opens the fastest file finder for the current project. Hitting <leader><leader> opens the general-purpose finder for everything else. The split is invisible after a day of use: muscle memory picks the right tool automatically because the keybindings are context-dependent.
fff.nvim is faster than snacks.picker for repeated searches1. After the index builds (which takes 2-3 seconds on first open for a 50,000-file repo)2, subsequent searches return results before I finish typing the third character of a query3. The frecency ranking means the file I want is usually at position 1 or 24.
References
- fff.nvim. The indexed file picker
- snacks.nvim dashboard docs. Dashboard configuration reference
- LazyVim UI plugins. Shows how LazyVim configures the snacks dashboard with
preset.keys
Footnotes
-
fff.nvim GitHub README: “On a 500k-file Chromium checkout, that is the difference between 3-9 SECONDS per ripgrep spawn and sub-10 ms per FFF query.” The Rust backend keeps the index resident in memory, avoiding the per-invocation re-indexing that CLI tools incur. Source: github.com/dmtrKovalenko/fff ↩
-
The cold-start index build time scales with repo size. The Chromium example (500k files) shows 3-9 seconds; 2-3 seconds for 50k files is a reasonable extrapolation based on the same indexing pipeline. Source: fff.nvim GitHub README ↩
-
fff.nvim claims “sub-10 ms per FFF query” on warm memory. At typical typing speed (~120ms per character), sub-10ms results appear before the third keystroke registers. Source: fff.nvim GitHub README ↩
-
fff.nvim uses frecency-based scoring as a core feature, ranking files by how often and recently they are opened. The blog post “Lessons in Performance” by the ff.nvim author explains that frecency-first heuristics surface frequently-opened files at the top. Source: elanmed.dev/blog/fuzzy-finder-lessons-in-performance ↩
This post was written with AI assistance.